Compare commits

...
121 changed files with 8732 additions and 1483 deletions

No files matched your search

+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
run: |
git diff --name-only --diff-filter=ACMR -z "origin/${BASE_REF}...HEAD" -- \
'*.css' '*.js' '*.json' '*.jsonc' '*.jsx' '*.md' '*.mdx' '*.scss' '*.ts' '*.tsx' '*.yaml' '*.yml' \
| xargs -0 -r pnpm exec oxfmt --check
| xargs -0 -r pnpm exec oxfmt --check --
+39 -4
View File
@@ -8,7 +8,7 @@ on:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
branches: [main, dev, beta, next, v2]
merge_group:
workflow_dispatch:
inputs:
@@ -34,8 +34,10 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: false
# Every event for a PR shares one group so a close event cancels stale image
# publication and a newer synchronize run cannot be overwritten by an older run.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' || github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
@@ -87,6 +89,17 @@ jobs:
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Validate Custom Widget architecture
run: pnpm check:custom-widgets
- name: Validate checked-in OpenAPI schema
run: pnpm check:openapi
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
@@ -229,7 +242,7 @@ jobs:
needs: fast-gate
if: |
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
runs-on: ubuntu-24.04-arm
@@ -286,7 +299,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
packages: write
pull-requests: read
env:
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
@@ -302,6 +317,26 @@ jobs:
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Verify PR head before publication
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
if (pull.state !== "open") {
core.setFailed(`PR #${pull.number} is ${pull.state}; refusing to publish its preview.`);
return;
}
if (pull.head.sha !== process.env.EXPECTED_HEAD_SHA) {
core.setFailed(`PR #${pull.number} moved from ${process.env.EXPECTED_HEAD_SHA} to ${pull.head.sha}.`);
}
- name: Publish preview manifest
if: "!env.ACT"
env:
-14
View File
@@ -2,22 +2,8 @@ name: Workshop
on:
pull_request:
paths:
- "apps/workshop/**"
- "apps/docs/**"
- "packages/workshop/**"
- "packages/custom-widgets/**"
- "pnpm-lock.yaml"
- ".github/workflows/workshop.yml"
push:
branches: [dev, v2]
paths:
- "apps/workshop/**"
- "apps/docs/**"
- "packages/workshop/**"
- "packages/custom-widgets/**"
- "pnpm-lock.yaml"
- ".github/workflows/workshop.yml"
permissions:
contents: read
@@ -0,0 +1,13 @@
---
title: Component reference
sidebar_position: 3
tags: [Custom Widgets, JSX, Mantine, Components]
---
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
# Custom JSX component reference
Search the components and properties exposed by Homarr's safe Custom JSX runtime. The live schema and diagnostics remain authoritative: properties marked as blocked cannot be passed from widget code, and denied components are shown only for explanation.
<CustomJsxComponentReference />
@@ -53,13 +53,14 @@ The browser renders JSX. Homarr's server executes named requests, applies board
- [Create a Custom Widget](./authoring) visually or with one copied AI prompt.
- [Use the JSX runtime](./custom-jsx) for data, Mantine components, temporary inputs, and diagnostics.
- [Browse the supported component reference](./component-reference).
- [Configure requests and security](./requests-and-security).
- [Use MCP and the skills.sh skill](./agent-authoring).
- [Correct problems](./troubleshooting).
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and only disable the widget preview.
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
Exports, imports, Workshop, MCP, and storage all validate the same schema. Old formats are rejected. A missing definition only renders **Custom widget unavailable** inside its tile.
Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the legacy format remain visible with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. Homarr keeps the legacy record until the replacement validates and is confirmed; credentials are never included in the prompt.
When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required.
@@ -2,9 +2,11 @@
## Correct an unavailable widget
The definition does not exist. Remove the unavailable tile from the board.
The definition does not exist. If it was deleted, remove the unavailable tile from the board.
Then, install or create a Custom JSX v2 definition. Homarr does not provide recovery or migration for old formats.
If the Custom Widgets page shows the definition with a **Migration required** badge, open its action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON from the same menu. Homarr keeps the legacy definition until the replacement validates and you confirm the migration.
Otherwise, install or create a Custom JSX v2 definition.
## Correct an invalid saved definition
@@ -12,9 +14,7 @@ The Custom Widgets page isolates a definition that does not conform to the curre
An invalid definition shows its validation errors. You can delete it.
You cannot edit, enable, export, duplicate, publish, or select an invalid definition. Recreate the definition or import a valid definition.
Homarr does not migrate old Custom JSX formats.
You cannot edit, enable, export, duplicate, publish, or select an invalid v2 definition. Recreate the definition or import a valid definition. Preserved v1 definitions use the separate **Migration required** workflow described above.
## Correct validation errors
@@ -30,7 +30,7 @@ An incomplete API source URL or icon URL causes a form validation error. The wor
The workbench marks the invalid field while you type. An invalid URL does not replace the form with the error boundary.
An invalid definition disables only the widget preview. The Data, Options, Actions, and Diagnostics tabs remain available.
An invalid definition disables widget preview and Save/Create. The Data, Options, Actions, and Diagnostics tabs remain available so you can correct it.
The Diagnostics tab shows the current schema errors.
+1 -1
View File
@@ -80,7 +80,7 @@ Because Workshop is still unreleased, reset an older development volume if it pr
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices.
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
Homarr instances use the server-side `WORKSHOP_API_URL` variable to reach the central API. The Docusaurus site uses `WORKSHOP_URL` when its development server runs separately; the combined production image always uses its own origin. Restart the corresponding process after changing either value because both are read at startup.
## Client architecture
@@ -61,7 +61,7 @@ import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } fro
import { formatRelativeTime } from "./format";
import { ScreenshotEditor } from "./ScreenshotEditor";
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
@@ -305,28 +305,16 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (!submission || voting.current) return;
voting.current = true;
const userId = await requireUserId("vote");
if (!userId) {
if (!(await requireUserId("vote"))) {
voting.current = false;
return;
}
const prev = userVote;
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setUserVote(
isToggleOff
? undefined
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
try {
setUserVote((await backend.vote(submission.id, value)) ?? undefined);
const reconciled = await voteAndReconcile(backend, submission.id, value);
setSubmission(reconciled.submission);
setUserVote(reconciled.votes.find((vote) => vote.submission === submission.id));
} catch (caught) {
setUserVote(prev);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
voting.current = false;
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ScreenshotGallery } from "./DetailSections";
vi.mock("@docusaurus/theme-common", () => ({ useColorMode: () => ({ colorMode: "light" }) }));
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function render(urls: string[]) {
await act(async () => root.render(<ScreenshotGallery urls={urls} title="Gallery" />));
}
describe("ScreenshotGallery", () => {
it("resets an out-of-range selection when screenshots shrink", async () => {
await render(["/one.png", "/two.png", "/three.png"]);
const thirdThumbnail = host.querySelector<HTMLButtonElement>('button[aria-label="Show screenshot 3"]');
if (!thirdThumbnail) throw new Error("Third screenshot thumbnail was not rendered");
await act(async () => thirdThumbnail.click());
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png");
await render(["/one.png"]);
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png");
expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull();
});
});
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@site/src/lib/utils";
import { clampScreenshotIndex } from "./workshop-utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const colorModeKeys = { dark: "dark", light: "light" } as const;
@@ -44,11 +45,19 @@ export const DetailSkeleton = () => (
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
const selectedIndex = clampScreenshotIndex(idx, urls.length);
useEffect(() => setIdx((current) => clampScreenshotIndex(current, urls.length)), [urls.length]);
if (urls.length === 0) return null;
return (
<div className="group/gallery w-full">
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
<img
className="h-full w-full object-contain"
src={urls[selectedIndex]}
alt={`${title} screenshot ${selectedIndex + 1}`}
/>
{urls.length > 1 && (
<>
<button
@@ -75,7 +84,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
<legend className="sr-only">Screenshot previews</legend>
<div className="flex gap-2 overflow-x-auto pb-1">
{urls.map((url, i) => {
const selected = i === idx;
const selected = i === selectedIndex;
return (
<button
type="button"
@@ -104,7 +104,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const reader = new FileReader();
reader.addEventListener("load", (e) => {
const text = e.target?.result as string;
if (!type) setType("customWidget");
setType("customWidget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
});
@@ -112,7 +112,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
reader.readAsText(jsonFile);
return true;
},
[type, step, title, description, setContentAndAutofill, goTo],
[step, title, description, setContentAndAutofill, goTo],
);
const addImageFiles = useCallback(
@@ -400,6 +400,7 @@ const StepType = ({
</div>
<input
type="file"
aria-label="Choose a widget JSON file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
@@ -7,7 +7,7 @@ import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./workshop-utils";
import { voteAndReconcile } from "./workshop-utils";
export type SortKey = "top" | "new" | "recent" | "discussed";
export type TypeFilter = "all" | "yours" | SubmissionType;
@@ -131,58 +131,32 @@ export const useWorkshop = (workshopUrl: string) => {
if (votingIds.current.has(submissionId)) return;
votingIds.current.add(submissionId);
const userId = await requireUserId("vote");
if (!userId) {
if (!(await requireUserId("vote"))) {
votingIds.current.delete(submissionId);
return;
}
const prev = votes[submissionId];
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setVotes((v) => {
const next = { ...v };
if (isToggleOff) delete next[submissionId];
else
next[submissionId] = {
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
value,
} as WorkshopVote;
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
),
);
try {
const saved = await backend.vote(submissionId, value);
setVotes((current) => {
const next = { ...current };
if (saved) next[submissionId] = saved;
else delete next[submissionId];
return next;
});
} catch (caught) {
setVotes((v) => {
const next = { ...v };
if (prev) next[submissionId] = prev;
else delete next[submissionId];
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
const reconciled = await voteAndReconcile(backend, submissionId, value);
setVotes(Object.fromEntries(reconciled.votes.map((row) => [row.submission, row])));
setSubmissions((current) =>
current.map((submission) =>
submission.id === submissionId
? {
...submission,
upvotes: reconciled.submission.upvotes,
downvotes: reconciled.submission.downvotes,
}
: submission,
),
);
} catch (caught) {
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
votingIds.current.delete(submissionId);
}
},
[backend, votes, requireUserId],
[backend, requireUserId],
);
const report = useCallback(
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { clampScreenshotIndex, voteAndReconcile } from "./workshop-utils";
describe("Workshop state reconciliation", () => {
it("reloads both the submission counts and authenticated vote after voting", async () => {
const submission = { id: "submission-1", upvotes: 8, downvotes: 2 } as WorkshopSubmission;
const vote = { id: "vote-1", submission: "submission-1", value: 1 } as WorkshopVote;
const backend = {
vote: vi.fn().mockResolvedValue(vote),
get: vi.fn().mockResolvedValue(submission),
listVotesForCurrentUser: vi.fn().mockResolvedValue([vote]),
};
await expect(voteAndReconcile(backend, submission.id, 1)).resolves.toEqual({ submission, votes: [vote] });
expect(backend.vote).toHaveBeenCalledWith(submission.id, 1);
expect(backend.get).toHaveBeenCalledWith(submission.id);
expect(backend.listVotesForCurrentUser).toHaveBeenCalledOnce();
});
it("clamps a selected screenshot when the gallery shrinks", () => {
expect(clampScreenshotIndex(3, 2)).toBe(1);
expect(clampScreenshotIndex(2, 0)).toBe(0);
});
});
@@ -1,4 +1,4 @@
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
@@ -9,8 +9,17 @@ export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
URL.revokeObjectURL(url);
};
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
if (!prev) return next === 1 ? [1, 0] : [0, 1];
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
return next === 1 ? [1, -1] : [-1, 1];
interface WorkshopVoteBackend {
vote(submissionId: string, value: 1 | -1): Promise<WorkshopVote | null>;
get(submissionId: string): Promise<WorkshopSubmission>;
listVotesForCurrentUser(): Promise<WorkshopVote[]>;
}
export const voteAndReconcile = async (backend: WorkshopVoteBackend, submissionId: string, value: 1 | -1) => {
await backend.vote(submissionId, value);
const [submission, votes] = await Promise.all([backend.get(submissionId), backend.listVotesForCurrentUser()]);
return { submission, votes };
};
export const clampScreenshotIndex = (index: number, screenshotCount: number) =>
Math.min(Math.max(0, index), Math.max(0, screenshotCount - 1));
+3010 -543
View File
File diff suppressed because it is too large. Load diff
@@ -1,18 +1,23 @@
"use client";
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
const t = useScopedI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
<Text size="sm">
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
{t("description")}{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
official discussion thread
{t("discussionLink")}
</Anchor>{" "}
will be taken into account. Happy Hacking!
{t("closing")}
</Text>
</Alert>
);
@@ -1,23 +1,26 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import {
IconBuildingStore,
IconCopy,
IconDots,
IconDownload,
IconSparkles,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import {
formatCustomWidgetImportIssues,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
@@ -28,15 +31,66 @@ interface WidgetRef {
name: string;
enabled: boolean;
valid: boolean;
migrationRequired: boolean;
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const deleteLegacyMutation = clientApi.customWidget.deleteLegacy.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const utils = clientApi.useUtils();
const [publishOpened, publishControls] = useDisclosure(false);
const [migrationOpened, migrationControls] = useDisclosure(false);
const migrationFileInputRef = useRef<HTMLInputElement>(null);
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const copyMigrationPrompt = async () => {
try {
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
await navigator.clipboard.writeText(result.prompt);
showSuccessNotification({
title: t("action.copyMigrationPrompt"),
message: t("notification.migrationPromptCopied"),
});
} catch (error) {
showErrorNotification({
title: t("action.copyMigrationPrompt"),
message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"),
});
}
};
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
try {
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
} catch {
showErrorNotification({
title: t("action.migrate"),
message: t("notification.migrationError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
});
reader.readAsText(file);
event.target.value = "";
};
const handleExport = async () => {
try {
@@ -77,23 +131,26 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
const callbacks = {
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
);
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
};
if (widget.migrationRequired) {
deleteLegacyMutation.mutate({ id: widget.id }, callbacks);
return;
}
deleteMutation.mutate({ id: widget.id }, callbacks);
},
});
};
@@ -125,104 +182,52 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Divider />
</>
)}
{widget.migrationRequired && (
<>
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
{t("action.copyMigrationPrompt")}
</Menu.Item>
<Menu.Item
onClick={() => migrationFileInputRef.current?.click()}
leftSection={<IconUpload {...iconProps} />}
>
{t("action.importMigration")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || deleteLegacyMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
</>
);
};
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const queueImport = useCallback(
(value: HomarrCustomWidgetV2) => {
setPendingImport(value);
openReview();
},
[openReview],
);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (e) => {
try {
const result = parseCustomWidgetClipboardDetailed(e.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
});
reader.readAsText(file);
event.target.value = "";
};
return (
<>
<MobileAffixButton
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingImport}
onClose={() => {
closeReview();
setPendingImport(null);
}}
/>
{widget.migrationRequired && (
<>
<input
ref={migrationFileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleMigrationFile}
aria-label={t("action.importMigration")}
/>
<CustomWidgetImportDialog
opened={migrationOpened}
widget={migratedWidget}
legacyId={widget.id}
onClose={() => {
migrationControls.close();
setMigratedWidget(null);
}}
/>
</>
)}
</>
);
};
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
filterSecretsForSourceAuthentication,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
loadPreviewQueries,
@@ -62,4 +63,18 @@ describe("Custom Widget workbench preview options", () => {
}),
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
});
it("drops credentials that do not apply after an authentication transition", () => {
expect(
filterSecretsForSourceAuthentication(
[
{ sourceId: "default", kind: "username", value: "admin", hasValue: true },
{ sourceId: "default", kind: "password", value: "secret", hasValue: true },
{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true },
],
"default",
"bearer",
),
).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]);
});
});
@@ -2,6 +2,7 @@ import { fetchApi } from "@homarr/api/client";
import {
customWidgetDefinitionSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetRequiredSecretKinds,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -33,6 +34,15 @@ export function getChangedSecrets(values: CustomWidgetFormValues) {
}));
}
export function filterSecretsForSourceAuthentication(
secrets: CustomWidgetFormValues["secrets"],
sourceId: string,
authType: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0],
) {
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
}
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.values,
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
@@ -39,6 +39,7 @@ import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handle
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -71,8 +72,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
useUnsavedChangesGuard(form.isDirty());
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const parsedOptions = useMemo(
@@ -117,13 +117,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
optionsSnapshot,
setOptionsSnapshot,
});
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (dirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, []);
useEffect(() => {
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions]);
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
expect(dialog).toContain("utils.customWidget.list.invalidate()");
expect(dialog).toContain("utils.customWidget.available.invalidate()");
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
});
});
@@ -1,7 +1,7 @@
"use client";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -70,7 +70,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Card
padding="sm"
withBorder={!widget.valid}
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
bd={
widget.migrationRequired
? "1px solid var(--mantine-color-yellow-6)"
: !widget.valid
? "1px solid var(--mantine-color-red-6)"
: undefined
}
style={{
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
transition: "opacity 150ms ease",
@@ -89,18 +95,34 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{!widget.valid && (
{widget.migrationRequired ? (
<Text size="xs" c="yellow.8">
{t("page.list.migrationDescription")}
</Text>
) : !widget.valid ? (
<Text size="xs" c="red">
{t("page.list.invalidDefinitionDescription")}
</Text>
)}
) : null}
{widget.sources[0] && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.sources.map((source) => source.origin).join(" · ")}
</Text>
)}
</Stack>
{widget.valid ? (
{widget.migrationRequired ? (
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconSparkles size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.migrationRequired")}
</Badge>
</Tooltip>
) : widget.valid ? (
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
@@ -166,7 +188,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
</>
)}
<CustomWidgetRowActions
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
</Group>
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
const requests = JSON.stringify({
status: { source: "default", path: "/status" },
restart: { source: "secondary", path: "/restart", kind: "action" },
logs: { source: "secondary", path: "/logs" },
});
describe("custom widget source dependencies", () => {
it("lists every request that would be removed with a source", () => {
expect(getDependentRequestIds(requests, "secondary")).toEqual(["restart", "logs"]);
});
it("removes only requests that use the selected source", () => {
expect(removeDependentRequests(requests, "secondary")).toEqual({
status: { source: "default", path: "/status" },
});
});
});
@@ -0,0 +1,27 @@
export function getDependentRequestIds(requestsJson: string, sourceId: string) {
const requests = parseRequestRecord(requestsJson);
return Object.entries(requests).flatMap(([requestId, request]) =>
isRecord(request) && request.source === sourceId ? [requestId] : [],
);
}
export function removeDependentRequests(requestsJson: string, sourceId: string) {
return Object.fromEntries(
Object.entries(parseRequestRecord(requestsJson)).filter(
([, request]) => !isRecord(request) || request.source !== sourceId,
),
);
}
function parseRequestRecord(requestsJson: string): Record<string, unknown> {
try {
const requests = JSON.parse(requestsJson) as unknown;
return isRecord(requests) ? requests : {};
} catch {
return {};
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,155 @@
"use client";
import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from "@mantine/core";
import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
type SecretKind = "apiKey" | "username" | "password";
const secretFields: Record<string, Array<{ kind: SecretKind }>> = {
bearer: [{ kind: "apiKey" }],
basic: [{ kind: "username" }, { kind: "password" }],
apiKeyHeader: [{ kind: "apiKey" }],
apiKeyQuery: [{ kind: "apiKey" }],
};
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
invalid: "baseUrlError.invalid",
protocol: "baseUrlError.protocol",
credentials: "baseUrlError.credentials",
queryOrFragment: "baseUrlError.queryOrFragment",
};
interface CustomWidgetSourceFieldProps {
source: CustomWidgetSource & { id: string };
index: number;
form: CustomWidgetWorkbenchForm;
definitionId?: string;
clearSecretPending: boolean;
onUpdate(index: number, changes: Partial<CustomWidgetSource> & { id?: string }): void;
onSetAuthentication(index: number, type: string): void;
onSetSecret(sourceId: string, kind: string, value: string): void;
onClearSecret(sourceId: string, kind: SecretKind): Promise<void>;
onRemove(index: number): void;
}
export function CustomWidgetSourceField({
source,
index,
form,
definitionId,
clearSecretPending,
onUpdate,
onSetAuthentication,
onSetSecret,
onClearSecret,
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useScopedI18n("customWidget.workbench.sources");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<Fieldset legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={source.id === "default" || Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => onUpdate(index, { id: value })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => onRemove(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
);
}
@@ -1,31 +1,18 @@
"use client";
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
bearer: [{ kind: "apiKey" }],
basic: [{ kind: "username" }, { kind: "password" }],
apiKeyHeader: [{ kind: "apiKey" }],
apiKeyQuery: [{ kind: "apiKey" }],
};
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
invalid: "baseUrlError.invalid",
protocol: "baseUrlError.protocol",
credentials: "baseUrlError.credentials",
queryOrFragment: "baseUrlError.queryOrFragment",
};
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
import { CustomWidgetSourceField } from "./_custom-widget-source-field";
export function CustomWidgetSourcesEditor({
form,
@@ -35,6 +22,8 @@ export function CustomWidgetSourcesEditor({
definitionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.sources");
const w = useScopedI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
@@ -83,6 +72,14 @@ export function CustomWidgetSourcesEditor({
const auth =
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
update(index, { auth: auth as CustomWidgetSource["auth"] });
form.setFieldValue(
"secrets",
filterSecretsForSourceAuthentication(
form.values.secrets,
sources[index]?.id ?? "",
type as Parameters<typeof filterSecretsForSourceAuthentication>[2],
),
);
};
const setSecret = (sourceId: string, kind: string, value: string) => {
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
@@ -111,7 +108,7 @@ export function CustomWidgetSourcesEditor({
),
);
};
const removeSource = (index: number) => {
const removeSourceAndDependents = (index: number) => {
const removedId = sources[index]?.id;
form.setFieldValue(
"sources",
@@ -122,25 +119,38 @@ export function CustomWidgetSourcesEditor({
),
);
if (!removedId) return;
form.setFieldValue(
"requests",
JSON.stringify(
Object.fromEntries(
Object.entries(
isRecord(parseJson(form.values.requests))
? (parseJson(form.values.requests) as Record<string, unknown>)
: {},
).filter(([, request]) => !isRecord(request) || request.source !== removedId),
),
null,
2,
),
);
form.setFieldValue("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2));
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
);
};
const removeSource = (index: number) => {
const removedId = sources[index]?.id;
if (!removedId) return;
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
if (dependentRequestIds.length === 0) {
removeSourceAndDependents(index);
return;
}
openConfirmModal({
title: t("remove"),
children: (
<Stack gap="xs">
<Text size="sm" fw={500}>
{w("requests.title")} ({dependentRequestIds.length})
</Text>
<List size="sm">
{dependentRequestIds.map((requestId) => (
<List.Item key={requestId}>{requestId}</List.Item>
))}
</List>
</Stack>
),
confirmProps: { children: t("remove") },
onConfirm: () => removeSourceAndDependents(index),
});
};
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
if (!definitionId) return;
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
@@ -153,109 +163,20 @@ export function CustomWidgetSourcesEditor({
return (
<Stack gap="sm">
{sources.map((source, index) => {
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
return (
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={source.id === "default" || Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => update(index, { id: value })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => update(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={typeof source.auth === "string" ? source.auth : source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })
}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })
}
/>
)}
{(secretFields[typeof source.auth === "string" ? source.auth : source.auth.type] ?? []).map((field) => {
const secret = form.values.secrets.find(
(entry) => entry.sourceId === source.id && entry.kind === field.kind,
);
const Input = field.kind === "username" ? TextInput : PasswordInput;
return (
<Group key={field.kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${field.kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretMutation.isPending}
onClick={() => void clearSecret(source.id, field.kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => removeSource(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
<CustomWidgetSourceField
key={source.id}
source={source}
index={index}
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretMutation.isPending}
onUpdate={update}
onSetAuthentication={setAuth}
onSetSecret={setSecret}
onClearSecret={clearSecret}
onRemove={removeSource}
/>
);
})}
<Accordion variant="contained">
@@ -0,0 +1,104 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import {
formatCustomWidgetImportIssues,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const queueImport = useCallback(
(value: HomarrCustomWidgetV2) => {
setPendingImport(value);
openReview();
},
[openReview],
);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
try {
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
});
reader.readAsText(file);
event.target.value = "";
};
return (
<>
<MobileAffixButton
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingImport}
onClose={() => {
closeReview();
setPendingImport(null);
}}
/>
</>
);
};
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
const cleanups: Array<() => void> = [];
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
document.body.replaceChildren();
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
});
describe("custom widget unsaved changes guard", () => {
it("blocks client-side link navigation and requests confirmation for a dirty form", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
const link = document.createElement("a");
link.href = "/en/manage/custom-widgets";
link.textContent = "Custom widgets";
document.body.append(link);
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
});
it("allows section links and navigation after the form is clean", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
let dirty = true;
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation }));
const sectionLink = document.createElement("a");
sectionLink.href = "#sources";
document.body.append(sectionLink);
expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(
true,
);
dirty = false;
const listLink = document.createElement("a");
listLink.href = "/en/manage/custom-widgets";
document.body.append(listLink);
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
expect(confirmNavigation).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,42 @@
interface UnsavedChangesGuardOptions {
isDirty(): boolean;
confirmNavigation(href: string): void;
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const eventTarget = event.target instanceof Element ? event.target : null;
const anchor = eventTarget?.closest<HTMLAnchorElement>("a[href]");
if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.origin !== current.origin) return;
if (
destination.pathname === current.pathname &&
destination.search === current.search &&
destination.hash !== current.hash
) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty()) return;
event.preventDefault();
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
const t = useI18n();
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const dirtyRef = useRef(isDirty);
const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
dirtyRef.current = isDirty;
confirmNavigationRef.current = (href) => {
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
confirmProps: { children: t("common.action.discard") },
onConfirm: () => router.push(href),
});
};
useEffect(
() =>
registerUnsavedChangesGuard({
isDirty: () => dirtyRef.current,
confirmNavigation: (href) => confirmNavigationRef.current(href),
}),
[],
);
}
@@ -11,7 +11,7 @@ import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
@@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { readConfigurationRequestBody } from "../body";
interface RouteContext {
params: Promise<{ token: string }>;
}
@@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) {
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as {
baseUrl?: unknown;
networkScope?: unknown;
secrets?: Record<string, unknown>;
} | null;
const parsedBody = await readConfigurationRequestBody(request);
if (parsedBody.status === "too-large") {
return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
}
if (parsedBody.status !== "ok") {
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body?.baseUrl,
networkScope: body?.networkScope,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
@@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body?.secrets?.[kind];
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
describe("custom widget configuration request body", () => {
it("rejects a streaming payload once it exceeds the byte budget", async () => {
const request = new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({
baseUrl: "https://example.com",
networkScope: "public",
secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
}),
});
await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
});
it("strictly parses a bounded configuration object", async () => {
const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
),
).resolves.toEqual({ status: "ok", data: body });
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({ ...body, unexpected: true }),
}),
),
).resolves.toEqual({ status: "invalid" });
});
});
@@ -0,0 +1,52 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
): Promise<
{ status: "ok"; data: z.infer<typeof configurationRequestBodySchema> } | { status: "invalid" | "too-large" }
> {
const contentLength = request.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
return { status: "too-large" };
}
if (!request.body) return { status: "invalid" };
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
byteLength += value.byteLength;
if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
await reader.cancel();
return { status: "too-large" };
}
chunks.push(value);
}
} catch {
return { status: "invalid" };
}
const bytes = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
const result = configurationRequestBodySchema.safeParse(candidate);
return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
} catch {
return { status: "invalid" };
}
}
@@ -0,0 +1,196 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
[`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
),
getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)),
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({})) },
}));
vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate,
}));
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" }));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
getCustomWidgetComponent: mocks.getComponent,
getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
getCustomWidgetSkillContent: () => "# Custom Widget Skill",
}));
vi.mock("@homarr/custom-widgets/core", () => ({
customJsxExamples: [{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" }],
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
}));
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] }));
import { POST } from "./route";
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number | null;
result?: Record<string, unknown>;
error?: { code: number; message: string };
}
beforeEach(() => {
mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } });
mocks.buildPrompt.mockClear();
mocks.getComponent.mockClear();
});
async function callMcp(method: string, params: Record<string, unknown> = {}, id = 1) {
const request = new Request("http://localhost/api/mcp/mcp", {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
ApiKey: "key.secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
});
const response = await POST(request as never);
const text = await response.text();
const data = response.headers.get("content-type")?.includes("text/event-stream")
? text
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length)
: text;
if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`);
return { response, body: JSON.parse(data) as JsonRpcResponse };
}
describe("authenticated MCP prompt protocol", () => {
test("initializes and advertises prompt and resource capabilities", async () => {
const { response, body } = await callMcp("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "route-test", version: "1.0.0" },
});
expect(response.status).toBe(200);
expect(body.error).toBeUndefined();
expect(body.result).toMatchObject({
protocolVersion: expect.any(String),
capabilities: { prompts: {}, resources: {} },
serverInfo: { name: "homarr", version: "test-version" },
});
expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
});
test("lists and interpolates the custom-widget authoring prompt", async () => {
const listed = await callMcp("prompts/list");
expect(listed.body.result).toEqual({
prompts: [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
],
});
const rendered = await callMcp("prompts/get", {
name: "homarr-custom-widget-author",
arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" },
});
expect(rendered.body.result).toMatchObject({
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user",
content: {
type: "text",
text: "Request: Build a status card\nDocumentation: https://docs.example.test/api",
},
},
],
});
expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api");
});
});
describe("authenticated MCP resource protocol", () => {
test("lists concrete resources and URI templates with reviewed MIME types", async () => {
const listed = await callMcp("resources/list");
expect(listed.body.result).toEqual({
resources: [
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
{
uri: "homarr://custom-widgets/examples/status-card",
name: "Custom Widget example: Status card",
mimeType: "application/json",
},
],
});
const templates = await callMcp("resources/templates/list");
expect(templates.body.result).toEqual({
resourceTemplates: [
expect.objectContaining({
uriTemplate: "homarr://custom-widgets/components/{name}",
mimeType: "application/json",
}),
expect.objectContaining({
uriTemplate: "homarr://custom-widgets/examples/{name}",
mimeType: "application/json",
}),
],
});
});
test.each([
["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }],
["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }],
["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"],
[
"homarr://custom-widgets/examples/status-card",
"application/json",
{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" },
],
["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }],
])("dispatches %s with its MIME type", async (uri, mimeType, expected) => {
const { body } = await callMcp("resources/read", { uri });
const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined;
const content = contents?.[0];
expect(content).toMatchObject({ uri, mimeType });
expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected);
});
});
describe("MCP protocol errors", () => {
test.each([
["prompts/get", { name: "unknown" }],
["resources/read", { uri: "homarr://custom-widgets/unknown" }],
])("returns JSON-RPC errors for unknown %s targets", async (method, params) => {
const { body } = await callMcp(method, params);
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) }));
});
test("returns a protocol error for a malformed resource request", async () => {
const { body } = await callMcp("resources/read", {});
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
});
});
@@ -249,8 +249,10 @@ const mcpHandler = createMcpHandler(
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
if (uri === "homarr://custom-widgets/schema") {
text = JSON.stringify(getCustomWidgetJsonSchema());
mimeType = "application/schema+json";
} else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";
@@ -24,6 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetImportDialogProps {
opened: boolean;
widget: HomarrCustomWidgetV2 | null;
legacyId?: string;
stackId?: string;
zIndex?: number;
onClose(): void;
@@ -33,6 +34,7 @@ interface CustomWidgetImportDialogProps {
export function CustomWidgetImportDialog({
opened,
widget,
legacyId,
stackId,
zIndex,
onClose,
@@ -59,19 +61,27 @@ export function CustomWidgetImportDialog({
};
}, [values, widget]);
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: (result) => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
},
onError: (error) => {
showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
},
});
const onSuccess = (result: { id: string }) => {
showSuccessNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: legacyId ? t("notification.migrated") : t("notification.imported"),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
};
const onError = (error: { message?: string }) => {
showErrorNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
});
};
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
const pending = importMutation.isPending || migrateMutation.isPending;
useEffect(() => {
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
@@ -84,7 +94,8 @@ export function CustomWidgetImportDialog({
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
),
);
mutation.mutate({ widget: configuredWidget, secrets });
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
else importMutation.mutate({ widget: configuredWidget, secrets });
};
return (
@@ -93,13 +104,13 @@ export function CustomWidgetImportDialog({
stackId={stackId}
zIndex={zIndex}
review={review}
pending={mutation.isPending}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -109,7 +120,7 @@ export function CustomWidgetImportDialog({
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
serializeWorkshopDefinition,
workshopDefinitionChanged,
} from "./workshop-publish-definition";
const definition = {
name: "Status",
sources: {
public: { name: "Public API", networkScope: "public" },
private: { name: "Home server", networkScope: "private" },
local: { networkScope: "loopback" },
},
};
describe("Workshop publish definition inspection", () => {
it("reports every source URL that requires review", () => {
expect(getPrivateWorkshopSourceNames(definition)).toEqual(["Home server", "local"]);
});
it("detects an export that changed after inspection", () => {
expect(workshopDefinitionChanged(definition, definition)).toBe(false);
expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
});
it("blocks publishing when the definition changes after inspection", async () => {
const publish = vi.fn(async () => undefined);
await expect(
publishWorkshopDefinition({
inspectedDefinition: definition,
refetchDefinition: async () => ({ ...definition, name: "Updated" }),
publish,
}),
).resolves.toBe("changed");
expect(publish).not.toHaveBeenCalled();
});
it("publishes the same refreshed export that passed the change check", async () => {
const publish = vi.fn(async () => undefined);
await expect(
publishWorkshopDefinition({
inspectedDefinition: definition,
refetchDefinition: async () => definition,
publish,
}),
).resolves.toBe("published");
expect(publish).toHaveBeenCalledWith(serializeWorkshopDefinition(definition));
});
});
@@ -0,0 +1,33 @@
interface PublishableWorkshopDefinition {
sources: Record<string, { name?: string; networkScope: string }>;
}
export const serializeWorkshopDefinition = (definition: unknown): string => {
const serialized = JSON.stringify(definition);
if (serialized === undefined) throw new Error("Workshop definition is not serializable");
return serialized;
};
export const workshopDefinitionChanged = (previous: unknown, current: unknown) =>
serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current);
export async function publishWorkshopDefinition({
inspectedDefinition,
refetchDefinition,
publish,
}: {
inspectedDefinition: unknown;
refetchDefinition: () => Promise<unknown | undefined>;
publish: (content: string) => Promise<unknown>;
}) {
const refreshedDefinition = await refetchDefinition();
if (refreshedDefinition === undefined) return "unavailable" as const;
if (workshopDefinitionChanged(inspectedDefinition, refreshedDefinition)) return "changed" as const;
await publish(serializeWorkshopDefinition(refreshedDefinition));
return "published" as const;
}
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
source.networkScope === "public" ? [] : [source.name ?? sourceId],
);
@@ -10,6 +10,11 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
serializeWorkshopDefinition,
} from "./workshop-publish-definition";
export function WorkshopPublishModal({
opened,
@@ -22,7 +27,6 @@ export function WorkshopPublishModal({
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const utils = clientApi.useUtils();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
@@ -31,10 +35,9 @@ export function WorkshopPublishModal({
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
([, source]) => source.networkScope !== "public",
);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -50,15 +53,33 @@ export function WorkshopPublishModal({
setPublished(false);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const publish = async () => {
setError(null);
try {
const definition = await utils.customWidget.export.fetch({ id: widget.id });
await createSubmission.mutateAsync({
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
screenshots,
if (!definition.data) throw new Error(t("publish.error"));
const inspectedDefinition = definition.data;
const result = await publishWorkshopDefinition({
inspectedDefinition,
refetchDefinition: async () => (await definition.refetch()).data,
publish: (content) =>
createSubmission.mutateAsync({
input: {
type: "customWidget",
title,
description,
content,
},
screenshots,
}),
});
if (result === "unavailable") throw new Error(t("publish.error"));
if (result === "changed") {
setSourceUrlsReviewed(false);
setError(t("publish.definitionChanged"));
return;
}
setPublished(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
@@ -113,10 +134,10 @@ export function WorkshopPublishModal({
value={screenshots}
onChange={setScreenshots}
/>
{privateSources.length > 0 && (
{privateSourceNames.length > 0 && (
<Alert color="yellow">
{t("publish.privateSourceWarning", {
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
sources: privateSourceNames.join(", "),
})}
<Checkbox
mt="sm"
@@ -126,10 +147,17 @@ export function WorkshopPublishModal({
/>
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<Button
loading={createSubmission.isPending}
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
loading={createSubmission.isPending || definition.isFetching}
disabled={
!user ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
}
onClick={() => void publish()}
>
{t("publish.action")}
+6 -2
View File
@@ -43,6 +43,9 @@ and SMTP transport in PocketBase. PocketBase emails the submission author about
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
failures are logged and never reject a comment, report, or deletion.
GitHub OAuth settings are synchronized from the environment on every PocketBase startup. Restart the Workshop after
rotating either credential; removing one or both values disables the provider instead of retaining stale credentials.
Run the disposable service integration test from the repository root:
```sh
@@ -52,8 +55,9 @@ pnpm test:workshop
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
roles into the normal `homarr-workshop_pb_data` volume.
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
The Workshop frontend validates submissions with `customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr
validates canonical content again before installation. PocketBase independently enforces publication invariants for
direct API requests, owns schema/revision fields, and rejects stale updates using the submitted expected revision.
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
+88
View File
@@ -0,0 +1,88 @@
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
const MAX_CSS_LENGTH = 16_384;
const MAX_CONTENT_LENGTH = 1_000_000;
const rejectRequest = (message) => {
throw new BadRequestError(message);
};
const validateWidgetManifest = (content) => {
let widget;
try {
widget = JSON.parse(content);
} catch {
rejectRequest("Widget content must be valid JSON");
}
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
rejectRequest("Widget name is invalid");
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
rejectRequest("Widget must define a default source");
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
for (const source of Object.values(widget.sources)) {
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
rejectRequest("Widget source URL is invalid");
if (!["public", "private", "loopback"].includes(source.networkScope))
rejectRequest("Widget source network scope is invalid");
}
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
rejectRequest("Widget requests are invalid");
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
rejectRequest("Widget options are invalid");
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
return JSON.stringify(widget);
};
const validateAndNormalizeSubmission = (record) => {
const type = record.getString("type");
const content = record.getString("content");
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
record.set("title", record.getString("title").trim());
record.set("description", record.getString("description").trim());
record.set("changelog", record.getString("changelog").trim());
if (type === "customCss") {
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
return;
}
if (type !== "customWidget") rejectRequest("Submission type is invalid");
record.set("content", validateWidgetManifest(content));
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
};
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const emailTemplate = (filename) => {
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
return $os.readFile($filepath.join(hooksPath, filename));
};
const sendEmail = (app, recipientEmail, subject, text, html) => {
const sender = app.settings().meta;
app.newMailClient().send(
new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject,
text,
html,
}),
);
};
module.exports = {
emailTemplate,
escapeHtml,
rejectRequest,
sendEmail,
validateAndNormalizeSubmission,
};
+44 -27
View File
@@ -1,5 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
onBootstrap((event) => {
event.next();
const users = event.app.findCollectionByNameOrId("users");
const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
const configured = Boolean(clientId && clientSecret);
users.oauth2.enabled = configured;
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
event.app.save(users);
});
onRecordCreateRequest((event) => {
const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
validateAndNormalizeSubmission(event.record);
event.record.set("revision", 1);
event.record.set("expectedRevision", 0);
event.record.set("changelog", "");
event.record.set("outdated", false);
event.next();
}, "submissions");
onRecordUpdateRequest((event) => {
const { rejectRequest, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
const original = event.record.original();
const currentRevision = original.getInt("revision");
const expectedRevision = event.record.getInt("expectedRevision");
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
rejectRequest("Submission changed since it was read");
}
validateAndNormalizeSubmission(event.record);
event.record.set("expectedRevision", expectedRevision);
event.record.set("revision", currentRevision + 1);
event.next();
}, "submissions");
onRecordCreateRequest((event) => {
event.record.set("status", "open");
event.next();
}, "reports");
onRecordAfterCreateSuccess((event) => {
try {
const votes = event.app.findCollectionByNameOrId("votes");
@@ -14,34 +54,9 @@ onRecordAfterCreateSuccess((event) => {
event.next();
}, "submissions");
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const emailTemplate = (filename) => {
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
return $os.readFile($filepath.join(hooksPath, filename));
};
const sendEmail = (app, recipientEmail, subject, text, html) => {
const sender = app.settings().meta;
app.newMailClient().send(
new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject,
text,
html,
}),
);
};
onRecordAfterCreateSuccess((event) => {
try {
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const recipient = event.app.findRecordById("users", submission.get("author"));
const commenter = event.app.findRecordById("users", event.record.get("author"));
@@ -78,10 +93,11 @@ onRecordAfterCreateSuccess((event) => {
onRecordAfterCreateSuccess((event) => {
try {
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
if (!publicOrigin || admins.length === 0) {
event.next();
@@ -122,6 +138,7 @@ onRecordAfterCreateSuccess((event) => {
}, "reports");
onRecordDeleteRequest((event) => {
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
const submissionId = event.record.id;
const submissionTitle = event.record.getString("title");
const authorId = event.record.get("author");
@@ -1,38 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
migrate(
(app) => {
let usersExisted = true;
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
usersExisted = false;
users = new Collection({ type: "auth", name: "users" });
}
users.passwordAuth = { enabled: false };
if (!users.fields.getByName("displayName"))
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
if (!users.fields.getByName("avatar"))
users.fields.add(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
if (!users.fields.getByName("githubUsername"))
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
const settings = app.settings();
const stateCollection = new Collection({
type: "base",
name: "workshop_migration_state",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [{ type: "text", name: "snapshot", required: true, max: 1_000_000 }],
});
app.save(stateCollection);
const state = {
usersExisted,
users: usersExisted
? {
passwordAuth: cloneJson(users.passwordAuth),
oauth2: cloneJson(users.oauth2),
listRule: users.listRule,
viewRule: users.viewRule,
createRule: users.createRule,
updateRule: users.updateRule,
deleteRule: users.deleteRule,
}
: null,
addedUserFields: [],
rateLimits: cloneJson(settings.rateLimits),
};
const addUserField = (field) => {
if (users.fields.getByName(field.name)) return;
users.fields.add(field);
state.addedUserFields.push(field.name);
};
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
}
users.passwordAuth = { enabled: false };
addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
addUserField(new URLField({ name: "avatarUrl" }));
addUserField(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
addUserField(new TextField({ name: "githubUsername", max: 100 }));
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new BoolField({ name: "isAdmin" }));
const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
@@ -41,6 +69,9 @@ migrate(
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.deleteRule = null;
app.save(users);
const stateRecord = new Record(stateCollection);
stateRecord.set("snapshot", JSON.stringify(state));
app.save(stateRecord);
const submissions = new Collection({
type: "base",
@@ -57,6 +88,7 @@ migrate(
{ type: "text", name: "widgetSchema", required: true, min: 1, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "number", name: "expectedRevision", onlyInt: true, min: 0 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "bool", name: "outdated" },
{
@@ -75,6 +107,18 @@ migrate(
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_created", false, "created", "");
app.save(submissions);
app
.db()
.newQuery(
`CREATE TRIGGER submissions_revision_cas
BEFORE UPDATE ON submissions
FOR EACH ROW
WHEN NEW.expectedRevision != OLD.revision OR NEW.revision != OLD.revision + 1
BEGIN
SELECT RAISE(ABORT, 'submission revision compare-and-swap failed');
END`,
)
.execute();
const votes = new Collection({
type: "base",
@@ -195,7 +239,6 @@ migrate(
});
app.save(listings);
const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
@@ -209,10 +252,41 @@ migrate(
app.save(settings);
},
(app) => {
let stateCollection;
let stateRecord;
try {
stateCollection = app.findCollectionByNameOrId("workshop_migration_state");
stateRecord = app.findFirstRecordByFilter(stateCollection.id, "");
} catch {
throw new Error("Workshop migration cannot be rolled back safely because its state snapshot is missing");
}
const state = JSON.parse(stateRecord.getString("snapshot"));
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {}
}
const settings = app.settings();
settings.rateLimits.enabled = state.rateLimits.enabled;
settings.rateLimits.rules = state.rateLimits.rules;
app.save(settings);
const users = app.findCollectionByNameOrId("users");
if (state.usersExisted) {
users.passwordAuth = state.users.passwordAuth;
users.oauth2 = state.users.oauth2;
users.listRule = state.users.listRule;
users.viewRule = state.users.viewRule;
users.createRule = state.users.createRule;
users.updateRule = state.users.updateRule;
users.deleteRule = state.users.deleteRule;
for (const name of state.addedUserFields) {
const field = users.fields.getByName(name);
if (field) users.fields.removeById(field.id);
}
app.save(users);
}
app.delete(stateCollection);
},
);
+28
View File
@@ -4,6 +4,9 @@ set -eu
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
@@ -30,3 +33,28 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
for client_id in workshop-test-client workshop-rotated-client; do
GITHUB_CLIENT_ID="$client_id" GITHUB_CLIENT_SECRET="workshop-test-secret" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml up -d --force-recreate workshop
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
exit 1
fi
sleep 1
done
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" EXPECTED_GITHUB_CLIENT_ID="$client_id" \
node apps/workshop/tests/oauth-sync.integration.mjs
done
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml stop workshop
printf 'y\n' | PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
migrate up --dir=/pb_data --migrationsDir=/pb_migrations
@@ -0,0 +1,27 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const expectedClientId = process.env.EXPECTED_GITHUB_CLIENT_ID;
if (!expectedClientId) throw new Error("EXPECTED_GITHUB_CLIENT_ID is required");
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return body;
};
const root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
});
const users = await request("/api/collections/users", {
headers: { authorization: `Bearer ${root.token}` },
});
const github = users.oauth2?.providers?.find((provider) => provider.name === "github");
if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
}
console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
@@ -0,0 +1,51 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
const read = (path) => readFile(resolve(path), "utf8");
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
const clientExport = packageJson.exports?.["./client"];
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
const hookUtils = await read("apps/workshop/pb_hooks/workshop-utils.js");
const schema = await read("packages/workshop/src/schema.ts");
const constantValue = (source, name) => {
const match = source.match(new RegExp(`(?:export )?const ${name} = ([\\d_]+)`));
if (!match?.[1]) throw new Error(`Workshop limit ${name} is missing or is not a numeric literal`);
return Number(match[1].replaceAll("_", ""));
};
if (constantValue(hookUtils, "MAX_CSS_LENGTH") !== constantValue(schema, "MAX_WORKSHOP_CSS_LENGTH")) {
throw new Error("PocketBase and shared Workshop CSS limits must match");
}
if (constantValue(hookUtils, "MAX_CONTENT_LENGTH") !== constantValue(schema, "MAX_WORKSHOP_CONTENT_LENGTH")) {
throw new Error("PocketBase and shared Workshop content limits must match");
}
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
}
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const required of [
"workshop_migration_state",
"addedUserFields",
"CREATE TRIGGER submissions_revision_cas",
"NEW.expectedRevision != OLD.revision",
"state.rateLimits.enabled",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const workflow = await read(".github/workflows/workshop.yml");
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
console.log("Workshop static contracts passed");
+173
View File
@@ -0,0 +1,173 @@
import { readFile } from "node:fs/promises";
import vm from "node:vm";
// oxlint-disable-next-line typescript/no-extraneous-class -- Mirrors the PocketBase Field constructor API.
class Field {
constructor(options) {
Object.assign(this, options);
this.id = `field-${options.name}`;
}
}
class Fields {
constructor(fields = []) {
this.values = fields.map((field) => (field instanceof Field ? field : new Field(field)));
}
getByName(name) {
return this.values.find((field) => field.name === name);
}
add(field) {
this.values.push(field);
}
removeById(id) {
this.values = this.values.filter((field) => field.id !== id);
}
}
class Collection {
constructor(options) {
Object.assign(this, options);
this.id = options.id ?? options.name;
this.fields = new Fields(options.fields);
}
addIndex() {}
}
let recordId = 0;
class Record {
constructor(collection) {
this.collection = collection;
this.id = `record-${++recordId}`;
this.data = new Map();
}
set(name, value) {
this.data.set(name, value);
}
getString(name) {
return String(this.data.get(name) ?? "");
}
}
const original = {
passwordAuth: { enabled: true, identityFields: ["email"] },
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
rules: {
listRule: "original-list",
viewRule: "original-view",
createRule: "original-create",
updateRule: "original-update",
deleteRule: "original-delete",
},
rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
};
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
users.passwordAuth = structuredClone(original.passwordAuth);
users.oauth2 = structuredClone(original.oauth2);
Object.assign(users, original.rules);
const settings = { rateLimits: structuredClone(original.rateLimits) };
const collections = new Map([[users.id, users]]);
const records = new Map();
const rawQueries = [];
const app = {
db() {
return {
newQuery(sql) {
return {
execute() {
rawQueries.push(sql);
},
};
},
};
},
findCollectionByNameOrId(name) {
const collection = collections.get(name);
if (!collection) throw new Error(`Missing collection ${name}`);
return collection;
},
findFirstRecordByFilter(collectionId) {
const record = records.get(collectionId)?.[0];
if (!record) throw new Error(`Missing state record ${collectionId}`);
return record;
},
save(value) {
if (value instanceof Collection) collections.set(value.id, value);
else if (value instanceof Record)
records.set(value.collection.id, [...(records.get(value.collection.id) ?? []), value]);
},
delete(collection) {
collections.delete(collection.id);
records.delete(collection.id);
},
settings() {
return settings;
},
};
let migration;
const sandbox = {
Collection,
Record,
TextField: Field,
URLField: Field,
FileField: Field,
BoolField: Field,
console,
migrate: (up, down) => {
migration = { up, down };
},
};
const source = await readFile("apps/workshop/pb_migrations/1784240000_workshop_widgets.js", "utf8");
vm.runInNewContext(source, sandbox);
if (!migration) throw new Error("Workshop migration did not register");
migration.up(app);
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
throw new Error("Workshop migration did not capture state before applying changes");
}
if (
!rawQueries.some(
(query) =>
query.includes("CREATE TRIGGER submissions_revision_cas") &&
query.includes("NEW.expectedRevision != OLD.revision"),
)
) {
throw new Error("Workshop migration did not install the atomic submission revision guard");
}
migration.down(app);
if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
throw new Error("Password authentication was not restored");
if (JSON.stringify(users.oauth2) !== JSON.stringify(original.oauth2))
throw new Error("OAuth settings were not restored");
for (const [name, value] of Object.entries(original.rules)) {
if (users[name] !== value) throw new Error(`User rule ${name} was not restored`);
}
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
throw new Error("Workshop user fields were not removed on rollback");
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
throw new Error("Rate limits were not restored");
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
}
collections.clear();
records.clear();
rawQueries.length = 0;
settings.rateLimits = structuredClone(original.rateLimits);
migration.up(app);
const bootstrappedUsers = collections.get("users");
if (!bootstrappedUsers) throw new Error("Fresh migration did not create the users collection");
migration.down(app);
if (collections.get("users") !== bootstrappedUsers) {
throw new Error("Fresh rollback removed the users collection required by the bootstrap hook");
}
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
if (collections.has(name)) throw new Error(`Fresh rollback left collection ${name}`);
}
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits)) {
throw new Error("Fresh rollback did not restore rate limits");
}
console.log("Workshop migration up/down restoration passed");
+62 -13
View File
@@ -105,21 +105,54 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
widgetSchema: "homarr-custom-css-v1",
content: JSON.stringify(widget),
author: author.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
revision: 999,
changelog: "Forged publication metadata",
outdated: true,
}),
});
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
submission.widgetSchema !== widget.$schema
submission.widgetSchema !== widget.$schema ||
submission.revision !== 1 ||
submission.changelog !== "" ||
submission.outdated !== false
) {
throw new Error("Submission publication failed");
throw new Error("Submission publication was not validated and normalized server-side");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customCss",
title: "Oversized stylesheet",
content: "x".repeat(16_385),
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Invalid widget payload",
content: ".dashboard { color: red; }",
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
@@ -132,17 +165,33 @@ if (
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
body: JSON.stringify({ title: "Updated runtime probe", expectedRevision: 1, revision: 500 }),
});
if (updatedSubmission.title !== "Updated runtime probe")
if (updatedSubmission.title !== "Updated runtime probe" || updatedSubmission.revision !== 2)
throw new Error("Authors must be able to edit their submission");
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
body: JSON.stringify({ outdated: true, expectedRevision: 2, revision: 900, changelog: "Needs a newer API" }),
});
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
throw new Error("PocketBase must own submission revision increments");
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const expectedRevision = 3 + attempt;
const concurrentUpdates = await Promise.all(
["Concurrent first", "Concurrent second"].map((title) =>
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({ title: `${title} ${attempt}`, expectedRevision }),
}),
),
);
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
throw new Error("Exactly one update may consume an expected submission revision");
}
}
await expectStatus(
@@ -240,7 +289,7 @@ const report = await request("/api/collections/reports/records", {
reporter: visitor.id,
category: "other",
explanation: "Runtime moderation test",
status: "open",
status: "dismissed",
}),
});
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
@@ -267,7 +316,7 @@ await expectStatus(
);
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
throw new Error("Workshop administrators must be able to review reports");
}
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
+22 -17
View File
@@ -1,9 +1,12 @@
import { chromium, expect } from "@playwright/test";
import type { Browser } from "@playwright/test";
import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { startMockApiContainerAsync } from "./shared/mock-api-container";
import type { MockApiServer } from "./shared/mock-api-server";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
@@ -13,18 +16,22 @@ const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>
describe("Custom JSX v2 workbench", () => {
test("creates, previews, edits, and immediately adds a widget to a board", async () => {
const mockApi = await startMockApiContainerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
const homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const page = await browser.newPage();
let mockApi: MockApiServer | undefined;
let homarrContainer: StartedTestContainer | undefined;
let browser: Browser | undefined;
try {
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
@@ -51,8 +58,8 @@ describe("Custom JSX v2 workbench", () => {
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
await expect(page.locator("#requests-editor-root")).toContainText('"$option": "label"');
await page.locator("#jsx-editor-root").fill(initialTemplate);
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
await page.locator("#jsx-editor").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
@@ -63,7 +70,7 @@ describe("Custom JSX v2 workbench", () => {
await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 });
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
await page.locator("#jsx-editor-root").fill(updatedTemplate);
await page.locator("#jsx-editor").fill(updatedTemplate);
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
@@ -84,9 +91,7 @@ describe("Custom JSX v2 workbench", () => {
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
await browser.close();
await homarrContainer.stop();
await mockApi.stop();
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
}
}, 180_000);
});
-27
View File
@@ -1,27 +0,0 @@
import { GenericContainer, Wait } from "testcontainers";
import type { StartedTestContainer } from "testcontainers";
export interface MockApiContainer {
url: string;
stop: () => Promise<void>;
}
export async function startMockApiContainerAsync(responseBody: unknown): Promise<MockApiContainer> {
const body = JSON.stringify(responseBody);
const script = [
'const { createServer } = require("node:http");',
`const body = ${JSON.stringify(body)};`,
'createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(8080, "0.0.0.0");',
].join("\n");
const container: StartedTestContainer = await new GenericContainer("node:24-alpine")
.withCommand(["node", "-e", script])
.withExposedPorts(8080)
.withWaitStrategy(Wait.forHttp("/status", 8080))
.start();
return {
url: `http://${container.getIpAddress("bridge")}:8080`,
stop: async () => void (await container.stop()),
};
}
+50
View File
@@ -0,0 +1,50 @@
import { createServer } from "node:http";
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
import type { Server } from "node:http";
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
export interface MockApiServer {
url: string;
close: () => Promise<void>;
}
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const candidate = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(body);
});
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Failed to start mock API server");
}
try {
await TestContainers.exposeHostPorts(address.port);
const client = await getContainerRuntimeClient();
const forwarder = (await client.container.list()).find(
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
);
const forwarderAddress = forwarder
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
: undefined;
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
return {
url: `http://${forwarderAddress}:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
} catch (error) {
server.close();
throw error;
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"clean:workspaces": "turbo clean",
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
"check:openapi": "tsx ./scripts/check-openapi-schema.mts",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
@@ -35,7 +36,7 @@
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project dom --coverage.enabled",
"test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project custom-widgets-node --project db-node --project dom --coverage.enabled",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e",
"test:workshop": "sh apps/workshop/test.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
@@ -26,7 +26,7 @@ import {
} from "./stored-definition";
import { templateProcedures } from "./template-procedures";
import { transferProcedures } from "./transfer-procedures";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
@@ -83,6 +83,12 @@ export const customWidgetRouter = createTRPCRouter({
updatedAt: new Date(),
}));
const sourceIds = Object.keys(definition.sources);
const changedSecretBindings = new Set(
Object.entries(definition.sources).flatMap(([sourceId, source]) => {
const previous = current.sources[sourceId];
return previous && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
}),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -101,6 +107,17 @@ export const customWidgetRouter = createTRPCRouter({
),
);
for (const sourceId of changedSecretBindings) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
),
);
}
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
@@ -139,6 +156,13 @@ export const customWidgetRouter = createTRPCRouter({
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const sourceId of changedSecretBindings) {
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
.run();
}
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
@@ -0,0 +1,79 @@
import { parse as parseSuperJson } from "superjson";
import type { legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
type LegacyCustomWidgetDefinition = typeof legacyCustomWidgetDefinitions.$inferSelect;
export function buildLegacyCustomWidgetMigrationPrompt(
definition: LegacyCustomWidgetDefinition,
configuredSecretKinds: readonly string[],
) {
const legacyDefinition = {
$schema: "homarr-custom-widget-v1",
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: redactLegacyUrl(definition.url),
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: redactLegacyRequestBody(definition.requestBody),
displayType: definition.displayType,
displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
configuredSecretKinds,
};
return buildCustomWidgetAiPrompt(
undefined,
undefined,
legacyDefinition,
[
"Migrate this preserved Homarr v1 Custom Widget to Custom JSX v2.",
"Preserve its visible behavior and API intent, use a source named default, and return importable v2 JSON plus JSX.",
"The URL and request-body values may be redacted; keep placeholders and never invent or include credentials.",
"Credential kinds are informational only and must be configured separately in Homarr.",
].join(" "),
);
}
function redactLegacyUrl(value: string) {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.hash = "";
url.pathname = url.pathname
.split("/")
.map((segment) => (segment ? "[REDACTED]" : segment))
.join("/");
for (const key of url.searchParams.keys()) url.searchParams.set(key, "[REDACTED]");
return url.toString();
} catch {
return "[INVALID LEGACY URL — enter the API URL in Homarr]";
}
}
function redactLegacyRequestBody(value: string | null) {
if (!value) return value;
try {
return JSON.stringify(redactAllValues(JSON.parse(value) as unknown));
} catch {
return "[REDACTED LEGACY REQUEST BODY]";
}
}
function redactAllValues(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redactAllValues);
if (value !== null && typeof value === "object")
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, redactAllValues(child)]));
if (value === null) return null;
return "[REDACTED VALUE]";
}
function parseLegacyDisplayConfig(value: string): unknown {
try {
return parseSuperJson(value) as unknown;
} catch {
return { migrationNote: "The stored v1 display configuration could not be decoded.", raw: value.slice(0, 4_000) };
}
}
@@ -2,18 +2,13 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
} from "@homarr/custom-widgets/core";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
import {
hashRuntimeParams,
@@ -23,66 +18,58 @@ import {
} from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getCustomWidgetCacheVersion } from "./cache-version";
import { buildLegacyCustomWidgetMigrationPrompt } from "./legacy-migration";
import {
mapAvailableCustomWidget,
mapCustomWidgetListItem,
mapLegacyAvailableCustomWidget,
mapLegacyCustomWidgetListItem,
} from "./management-query-mappers";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget:management" });
export const managementQueryProcedures = {
list: manageProcedure
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
const [definitions, legacyDefinitions] = await Promise.all([
ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
with: { secrets: true },
}),
ctx.db.query.legacyCustomWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
with: { secrets: true },
}),
]);
const current = definitions.map(mapCustomWidgetListItem);
const legacy = legacyDefinitions.map(mapLegacyCustomWidgetListItem);
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
}),
legacyMigrationPrompt: manageProcedure
.meta({
mcp: {
enabled: true,
description:
"Get a redacted AI migration prompt for one preserved legacy Custom Widget. Credentials must be configured separately.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.id),
with: { secrets: true },
});
return definitions.map((definition) => {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
id: definition.id,
issueCount: result.issues.length,
});
return {
id: definition.id,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: [],
requestCount: 0,
missingSecrets: [],
options: {},
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: false as const,
validationIssues: result.issues,
};
}
const widget = result.widget;
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
),
options: widget.options,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: true as const,
validationIssues: [],
};
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
return {
id: definition.id,
version: 1 as const,
prompt: buildLegacyCustomWidgetMigrationPrompt(
definition,
definition.secrets.map(({ kind }) => kind),
),
};
}),
get: manageProcedure
@@ -119,45 +106,13 @@ export const managementQueryProcedures = {
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.flatMap((definition) => {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
return [];
}
const widget = result.widget;
return {
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission: permission,
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
invalidates,
}),
),
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
request.kind === "query" ? [{ id }] : [],
),
updatedAt: definition.updatedAt,
};
const available = definitions.flatMap(mapAvailableCustomWidget);
if (!input.currentId || available.some(({ id }) => id === input.currentId)) return available;
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.currentId),
});
if (!legacy) return available;
return [...available, mapLegacyAvailableCustomWidget(legacy)];
}),
optionRequest: protectedProcedure
@@ -191,7 +146,7 @@ export const managementQueryProcedures = {
definitionId: input.definitionId,
});
try {
const values = resolveCustomWidgetRequestValues(request, input.params, {});
const values = resolveOptionRequestValues(request, input.params);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
baseUrl: source.baseUrl,
@@ -230,3 +185,21 @@ export const managementQueryProcedures = {
}
}),
};
export function resolveOptionRequestValues(
request: Parameters<typeof resolveCustomWidgetRequestValues>[0],
configuration: NonNullable<Parameters<typeof resolveCustomWidgetRequestValues>[2]>,
) {
const references = collectCustomWidgetRequestReferences(request);
const options: Record<string, unknown> = {};
const params: typeof configuration = {};
for (const name of references.options) {
const value = configuration[name];
if (value !== undefined) options[name] = value;
}
for (const name of references.params) {
const value = configuration[name];
if (value !== undefined) params[name] = value;
}
return resolveCustomWidgetRequestValues(request, options, params);
}
@@ -0,0 +1,154 @@
import type { InferSelectModel } from "drizzle-orm";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
} from "@homarr/custom-widgets/core";
import { safeParseStoredCustomWidgetDefinition } from "./stored-definition";
const logger = createLogger({ module: "custom-widget:management" });
type StoredDefinition = InferSelectModel<typeof customWidgetDefinitions>;
type StoredSecret = InferSelectModel<typeof customWidgetSecrets>;
type LegacyDefinition = InferSelectModel<typeof legacyCustomWidgetDefinitions>;
export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets: readonly StoredSecret[] }) {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
id: definition.id,
issueCount: result.issues.length,
});
return {
id: definition.id,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: [],
requestCount: 0,
missingSecrets: [],
options: {},
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: false as const,
migrationRequired: false as const,
validationIssues: result.issues,
};
}
const widget = result.widget;
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
),
options: widget.options,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: true as const,
migrationRequired: false as const,
validationIssues: [],
};
}
export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
return {
id: definition.id,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: [{ id: "default", name: "Legacy API", origin: safeOrigin(definition.url), authType: definition.authType }],
requestCount: 1,
missingSecrets: [],
options: {},
updatedAt: definition.updatedAt,
enabled: false,
valid: false as const,
migrationRequired: true as const,
validationIssues: [],
};
}
export function mapAvailableCustomWidget(definition: StoredDefinition) {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
return [];
}
const widget = result.widget;
return [
{
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission: permission,
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
invalidates,
}),
),
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
request.kind === "query" ? [{ id }] : [],
),
updatedAt: definition.updatedAt,
migrationRequired: false as const,
},
];
}
export function mapLegacyAvailableCustomWidget(definition: LegacyDefinition) {
return {
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
options: {},
defaultOptions: {},
template: "",
sources: [],
requestCapabilities: [],
optionRequests: [],
updatedAt: definition.updatedAt,
migrationRequired: true as const,
};
}
function safeOrigin(value: string) {
try {
return new URL(value).origin;
} catch {
return "Invalid legacy URL";
}
}
@@ -14,6 +14,8 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
@@ -48,8 +50,32 @@ const previewCreateProcedure = manageProcedure
with: { secrets: true },
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
const submittedSource = input.definition.sources[sourceId];
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
const missingReplacement = requiredSecretKinds(authType).some(
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
);
if (missingReplacement) {
throw new TRPCError({
code: ctx.session.user.permissions.includes("custom-widget-secret-write") ? "BAD_REQUEST" : "FORBIDDEN",
message: "Source security settings changed; re-enter its credentials to preview this definition",
});
}
}
for (const secret of existing.secrets) {
if (!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)) {
const existingSource = existingDefinition.sources[secret.sourceId];
const submittedSource = input.definition.sources[secret.sourceId];
if (
existingSource &&
submittedSource &&
hasSameSecretBinding(existingSource, submittedSource) &&
!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)
) {
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
}
}
@@ -14,6 +14,14 @@ import { toTrpcError } from "./domain-error";
const SESSION_PREFIX = "custom-widget:preview-session:";
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
const COMPARE_AND_SWAP_SESSION_SCRIPT = `
local current = redis.call('GET', KEYS[1])
if not current then return 0 end
local ok, parsed = pcall(cjson.decode, current)
if not ok or type(parsed) ~= 'table' or tonumber(parsed.revision) ~= tonumber(ARGV[1]) then return 0 end
redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
return 1
`;
class RedisPreviewSessionStore implements PreviewSessionStore {
public constructor(private readonly redis: RedisClient) {}
@@ -25,6 +33,20 @@ class RedisPreviewSessionStore implements PreviewSessionStore {
public getSession(id: string) {
return this.redis.get(`${SESSION_PREFIX}${id}`);
}
public async compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number) {
return (
Number(
await this.redis.eval(
COMPARE_AND_SWAP_SESSION_SCRIPT,
1,
`${SESSION_PREFIX}${id}`,
expectedRevision,
JSON.stringify(value),
ttlMs,
),
) === 1
);
}
public async deleteSession(id: string) {
await this.redis.del(`${SESSION_PREFIX}${id}`);
}
@@ -13,15 +13,16 @@ if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return { count, redis.call('PTTL', KEYS[1]) }
`;
const ACQUIRE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
return count
local time = redis.call('TIME')
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[3]), ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1
`;
const RELEASE_SCRIPT = `
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
return redis.call('DECR', KEYS[1])
return redis.call('ZREM', KEYS[1], ARGV[1])
`;
class RedisRequestLimitStore implements RequestLimitStore {
@@ -32,12 +33,12 @@ class RedisRequestLimitStore implements RequestLimitStore {
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
}
public async acquireConcurrency(key: string, limit: number, ttlMs: number) {
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
public async acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number) {
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, ownerId, limit, ttlMs)) > 0;
}
public async releaseConcurrency(key: string) {
await this.redis.eval(RELEASE_SCRIPT, 1, key);
public async releaseConcurrency(key: string, ownerId: string) {
await this.redis.eval(RELEASE_SCRIPT, 1, key, ownerId);
}
}
@@ -1,11 +1,28 @@
import { TRPCError } from "@trpc/server";
import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
import {
getCustomWidgetRequiredSecretKinds,
hasSameCustomWidgetSourceAuthentication,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } 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" }[],
@@ -16,7 +16,7 @@ import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { getPreviewSession } from "./preview-sessions";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
@@ -107,6 +107,7 @@ export const secretProcedures = {
assertSecretSources({ [input.sourceId]: source }, input.secrets);
const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() };
const bindingChanged = !hasSameSecretBinding(current, source);
const secretRows = input.secrets.map((secret) => ({
definitionId: input.definitionId,
sourceId: input.sourceId,
@@ -121,6 +122,16 @@ export const secretProcedures = {
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
if (bindingChanged) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
),
);
}
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
@@ -142,6 +153,17 @@ export const secretProcedures = {
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
if (bindingChanged) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
),
)
.run();
}
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
@@ -181,6 +203,12 @@ export const secretProcedures = {
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
if (!ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Creating a stored credential setup link requires dedicated permission",
});
}
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
@@ -5,7 +5,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
@@ -120,11 +120,28 @@ export const templateProcedures = {
}
const template = validateTemplate(definition, lines.join("\n"));
await ctx.db
const updateResult = (await ctx.db
.update(customWidgetDefinitions)
.set({ template, updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
.where(
and(eq(customWidgetDefinitions.id, input.id), eq(customWidgetDefinitions.template, definition.template)),
)) as unknown;
if (getAffectedRowCount(updateResult) === 0) {
throw new TRPCError({
code: "CONFLICT",
message: "Template changed since it was read. Read it again and retry.",
});
}
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
return { id: input.id, template, templateLines: lines, revision: getTemplateRevision(template) };
}),
};
function getAffectedRowCount(result: unknown): number {
if (Array.isArray(result)) return getAffectedRowCount(result[0]);
if (!result || typeof result !== "object") return 0;
if ("affectedRows" in result && typeof result.affectedRows === "number") return result.affectedRows;
if ("rowCount" in result && typeof result.rowCount === "number") return result.rowCount;
if ("changes" in result && typeof result.changes === "number") return result.changes;
return 0;
}
@@ -1,15 +1,16 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
@@ -37,4 +38,132 @@ export const transferProcedures = {
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
migrateLegacy: manageProcedure
.meta({
mcp: {
enabled: true,
description:
"Replace one preserved legacy Custom Widget with a validated v2 definition while retaining compatible encrypted credentials.",
},
})
.input(
z.object({
id: z.string(),
widget: customWidgetImportSchema,
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing migrated widget secrets requires dedicated permission",
});
}
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.id),
with: { secrets: true },
}),
ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id) }),
]);
if (!legacy) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
if (current) throw new TRPCError({ code: "CONFLICT", message: "A v2 widget already uses this identifier" });
const explicitKeys = new Set(input.secrets.map(({ sourceId, kind }) => `${sourceId}:${kind}`));
const preservableKinds = new Set<string>(requiredSecretKinds(getAuthType(input.widget.sources.default?.auth)));
const preservedSecrets = canPreserveLegacySecrets(legacy, input.widget)
? legacy.secrets.filter(({ kind }) => preservableKinds.has(kind) && !explicitKeys.has(`default:${kind}`))
: [];
const now = new Date();
const definitionRow = {
id: legacy.id,
...serializeCustomWidgetDefinition(input.widget),
enabled: legacy.enabled,
createdAt: legacy.createdAt,
updatedAt: now,
creatorId: legacy.creatorId,
};
const secretRows = [
...preservedSecrets.map(({ kind, encryptedValue }) => ({
definitionId: legacy.id,
sourceId: "default",
kind,
encryptedValue,
updatedAt: now,
})),
...input.secrets.map(({ sourceId, kind, value }) => ({
definitionId: legacy.id,
sourceId,
kind,
encryptedValue: encryptSecret(value),
updatedAt: now,
})),
];
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
await transaction
.delete(schema.legacyCustomWidgetDefinitions)
.where(eq(schema.legacyCustomWidgetDefinitions.id, legacy.id));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
transaction
.delete(legacyCustomWidgetDefinitions)
.where(eq(legacyCustomWidgetDefinitions.id, legacy.id))
.run();
});
},
});
logger.info("Migrated legacy custom widget definition", {
id: legacy.id,
preservedSecretCount: preservedSecrets.length,
});
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
}),
deleteLegacy: manageProcedure
.meta({
mcp: {
enabled: true,
description: "Permanently delete one preserved legacy Custom Widget that will not be migrated.",
},
})
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(legacyCustomWidgetDefinitions).where(eq(legacyCustomWidgetDefinitions.id, input.id));
logger.info("Deleted legacy custom widget definition", { id: input.id });
}),
};
function canPreserveLegacySecrets(
legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
widget: z.infer<typeof customWidgetImportSchema>,
) {
const source = widget.sources.default;
if (!source || getAuthType(source.auth) !== legacy.authType) return false;
if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false;
const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
return headerName === (legacy.headerName ?? undefined);
}
function getAuthType(auth: string | { type: string } | undefined) {
return typeof auth === "string" ? auth : (auth?.type ?? "none");
}
function getOrigin(value: string) {
try {
return new URL(value).origin.toLowerCase();
} catch {
return null;
}
}
@@ -1,7 +1,13 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq, sql } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
import {
customWidgetDefinitions,
customWidgetSecrets,
legacyCustomWidgetDefinitions,
legacyCustomWidgetSecrets,
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
@@ -18,14 +24,28 @@ const session = {
},
expires: new Date().toISOString(),
} satisfies Session;
const manageOnlySession = {
...session,
user: { ...session.user, permissions: ["custom-widget-manage"] },
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
const jellyfinDefaultSource = jellyfin.sources.default;
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
function createManageOnlyCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({
db,
deviceType: undefined,
session: manageOnlySession,
});
}
async function prepareDatabase() {
const db = createDb();
await db.insert(users).values({ id: userId });
@@ -42,9 +62,69 @@ function rejectSecretInserts(db: ReturnType<typeof createDb>) {
);
}
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const id = "legacy-jellyfin";
const encryptedValue = "preserved.secret" as `${string}.${string}`;
await db.insert(legacyCustomWidgetDefinitions).values({
id,
name: "Legacy Jellyfin",
url: "http://jellyfin.local/Items/Counts",
authType: "apiKeyHeader",
headerName: "X-Emby-Token",
method: "GET",
displayType: "singleValue",
enabled: false,
creatorId: userId,
});
await db.insert(legacyCustomWidgetSecrets).values({
definitionId: id,
kind: "apiKey",
encryptedValue,
updatedAt: new Date(0),
});
return { id, encryptedValue };
}
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("atomically replaces a confirmed legacy widget while preserving its stable ID and credential", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
expect(await db.query.legacyCustomWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
enabled: false,
creatorId: userId,
});
expect(await db.query.customWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
sourceId: "default",
kind: "apiKey",
encryptedValue: legacy.encryptedValue,
});
});
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
rejectSecretInserts(db);
await expect(createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] })).rejects.toThrow(
"forced secret insert failure",
);
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({ id: legacy.id });
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
});
test("rolls back create when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -155,4 +235,99 @@ describe("custom widget definition persistence", () => {
widgetName: "Living room Jellyfin",
});
});
test("does not inherit a stored credential after a preview source binding changes", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const changedDefinition = {
...jellyfin,
sources: {
...jellyfin.sources,
default: { ...jellyfinDefaultSource, baseUrl: "https://attacker.example" },
},
};
await expect(
createManageOnlyCaller(db).previewCreate({
definitionId: created.id,
definition: changedDefinition,
secrets: [],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
caller.previewCreate({ definitionId: created.id, definition: changedDefinition, secrets: [secret] }),
).resolves.toMatchObject({ success: true });
});
test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "https://other.example/api",
networkScope: "public",
secrets: [],
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("clears persisted credentials when a general definition update changes its binding", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).update({
id: created.id,
sources: {
...jellyfin.sources,
default: { ...jellyfinDefaultSource, baseUrl: "https://other.example/api" },
},
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("requires secret-write permission to create a stored-definition setup link", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
await expect(
createManageOnlyCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("allows only one concurrent template patch for a revision", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, template: "<Text>Original</Text>", secrets: [] });
const read = await caller.readTemplate({ id: created.id });
const results = await Promise.allSettled([
caller.templatePatch({
id: created.id,
expectedRevision: read.revision,
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>First</Text>"] }],
}),
caller.templatePatch({
id: created.id,
expectedRevision: read.revision,
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>Second</Text>"] }],
}),
]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
});
});
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
describe("legacy custom widget migration prompt", () => {
test("describes the legacy widget without exposing URL or body credentials", () => {
const prompt = buildLegacyCustomWidgetMigrationPrompt(
{
id: "legacy",
name: "Private service",
description: "Status",
iconUrl: null,
url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
authType: "bearer",
headerName: null,
method: "POST",
requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
displayType: "singleValue",
displayConfig: JSON.stringify({ json: { type: "singleValue", jsonPath: "$.status" } }),
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
creatorId: null,
},
["apiKey"],
);
expect(prompt).toContain("homarr-custom-widget-v1");
expect(prompt).toContain("homarr-custom-widget-v2");
expect(prompt).toContain("configuredSecretKinds");
expect(prompt).not.toContain("top-secret");
expect(prompt).not.toContain("body-secret");
expect(prompt).not.toContain("admin:password");
expect(prompt).not.toContain("path-token");
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
import { resolveOptionRequestValues } from "../../custom-widget/management-queries";
const loadRequest: CustomJsxRequest = {
source: "default",
@@ -32,3 +33,17 @@ describe("preview request option bindings", () => {
});
});
});
describe("configuration option request bindings", () => {
it("resolves option configuration and runtime parameters in their own slots", () => {
const request: CustomJsxRequest = {
...loadRequest,
path: "/environments/{option:environmentId}/containers/{param:containerId}",
};
expect(resolveOptionRequestValues(request, { environmentId: 7, containerId: 12, unrelated: true })).toEqual({
"option:environmentId": 7,
containerId: 12,
});
});
});
+14 -2
View File
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
getCustomWidgetConfirmation,
@@ -81,7 +81,19 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
if (!stored) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
if (!stored) {
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
columns: { id: true },
});
if (legacy) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
});
}
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
}
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
+203 -106
View File
@@ -1,115 +1,212 @@
import { expect, test, vi } from "vitest";
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { serverSettingsRouter } from "../router/serverSettings";
import { createTRPCRouter } from "../trpc";
import { mcpRouter } from "../mcp";
vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
board: boardRouter,
customWidget: customWidgetRouter,
info: infoRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
const MCP_TOOL_ALLOWLIST = {
query: [
"apiKeys_getAll",
"app_all",
"app_byId",
"app_getPaginated",
"app_search",
"bazarr_getBadges",
"beszel_getAlerts",
"beszel_getSystems",
"beszel_getSystemStats",
"board_getAllBoards",
"calendar_findAllEvents",
"customWidget_get",
"customWidget_getAuthoringPrompt",
"customWidget_getSkill",
"customWidget_legacyMigrationPrompt",
"customWidget_list",
"customWidget_previewJournal",
"customWidget_previewQuery",
"customWidget_schema",
"customWidget_validate",
"customWidget_workshopGet",
"customWidget_workshopSearch",
"dnsHole_summary",
"docker_getContainers",
"docker_logs",
"downloads_getJobsAndStatuses",
"healthMonitoring_getClusterHealthStatus",
"healthMonitoring_getSystemHealthStatus",
"healthMonitoring_listStorageVolumes",
"icon_findIcons",
"info_getInfo",
"integration_all",
"integration_byId",
"integration_getKinds",
"integration_getMediaRequestOptions",
"integration_mediaRequestSearchTargets",
"integration_search",
"integration_searchInIntegration",
"integration_searchMediaRequests",
"invite_getAll",
"mediaRequests_getLatestRequests",
"mediaRequests_getStats",
"mediaServer_getCurrentStreams",
"patchmon_getStats",
"serverSettings_getBoardSettings",
"smartHome_entityState",
"widgetSecrets_getConfiguredKinds",
],
mutation: [
"app_create",
"app_delete",
"app_update",
"board_addItem",
"board_changeBoardVisibility",
"board_createBoard",
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
"customWidget_delete",
"customWidget_deleteLegacy",
"customWidget_previewAction",
"customWidget_templatePatch",
"dnsHole_disable",
"dnsHole_enable",
"docker_removeAll",
"docker_restartAll",
"docker_startAll",
"docker_stopAll",
"downloads_pause",
"downloads_resume",
"integration_create",
"integration_delete",
"integration_requestMedia",
"invite_createInvite",
"invite_deleteInvite",
"mediaRequests_answerRequest",
"serverSettings_updateBoardSettings",
"smartHome_executeAutomation",
"smartHome_switchEntity",
],
secret: [
"apiKeys_create",
"apiKeys_delete",
"customWidget_configurationRequestUser",
"customWidget_create",
"customWidget_migrateLegacy",
"customWidget_previewCreate",
"customWidget_secretSet",
"customWidget_sourceConfigure",
"customWidget_update",
"customWidget_workshopInstall",
"widgetSecrets_deleteSecret",
"widgetSecrets_setSecret",
],
} as const;
type ToolClassification = keyof typeof MCP_TOOL_ALLOWLIST;
function expectedToolInventory() {
return Object.entries(MCP_TOOL_ALLOWLIST)
.flatMap(([classification, names]) =>
names.map((name) => ({ name, classification: classification as ToolClassification })),
)
.toSorted((left, right) => left.name.localeCompare(right.name));
}
function actualToolInventory() {
const secretTools = new Set<string>(MCP_TOOL_ALLOWLIST.secret);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
>;
return extractToolsFromProcedures(mcpRouter)
.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
return {
name: tool.name,
classification: secretTools.has(tool.name) ? ("secret" as const) : type,
};
})
.toSorted((left, right) => left.name.localeCompare(right.name));
}
describe("production MCP router", () => {
test("matches the reviewed tool and permission inventory exactly", () => {
expect(actualToolInventory()).toEqual(expectedToolInventory());
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractToolsFromProcedures(mcpRouter);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
>;
for (const name of MCP_TOOL_ALLOWLIST.secret) {
const tool = tools.find((candidate) => candidate.name === name);
expect(tool, `Missing reviewed secret tool '${name}'`).toBeDefined();
const procedure = tool ? procedures[tool.pathInRouter.join(".")] : undefined;
expect(procedure?.["_def"]?.type, `Secret tool '${name}' must remain a mutation`).toBe("mutation");
}
});
test("gives every production tool a description", () => {
const tools = extractToolsFromProcedures(mcpRouter);
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
);
});
});
test("MCP tools should be extractable from the router", () => {
const act = () => extractToolsFromProcedures(mcpTestRouter);
describe("custom widget authoring procedure access", () => {
const unauthenticatedCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: null,
});
expect(act).not.toThrow();
});
test("MCP tools should contain expected procedures", () => {
const tools = extractToolsFromProcedures(mcpTestRouter);
const toolNames = tools.map((tool) => tool.name);
expect(tools.length).toBeGreaterThan(0);
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("customWidget_schema");
expect(toolNames).toContain("customWidget_validate");
expect(toolNames).toContain("customWidget_list");
expect(toolNames).toContain("customWidget_get");
expect(toolNames).toContain("customWidget_templatePatch");
expect(toolNames).toContain("customWidget_create");
expect(toolNames).toContain("customWidget_update");
expect(toolNames).toContain("customWidget_delete");
expect(toolNames).toContain("customWidget_previewCreate");
expect(toolNames).toContain("customWidget_previewQuery");
expect(toolNames).toContain("customWidget_previewAction");
expect(toolNames).toContain("customWidget_previewJournal");
expect(toolNames).toContain("customWidget_secretSet");
expect(toolNames).toContain("customWidget_sourceConfigure");
expect(toolNames).toContain("customWidget_configurationRequestUser");
expect(toolNames).toContain("customWidget_workshopSearch");
expect(toolNames).toContain("customWidget_workshopGet");
expect(toolNames).toContain("customWidget_workshopInstall");
expect(toolNames).toContain("customWidget_getAuthoringPrompt");
expect(toolNames).toContain("customWidget_getSkill");
expect(toolNames).not.toContain("customWidget_import");
expect(toolNames).not.toContain("customWidget_export");
expect(toolNames).not.toContain("customWidget_simulatePreviewAction");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
expect(toolNames).toContain("serverSettings_getBoardSettings");
expect(toolNames).toContain("serverSettings_updateBoardSettings");
});
test("MCP tools should have descriptions", () => {
const tools = extractToolsFromProcedures(mcpTestRouter);
for (const tool of tools) {
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
}
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
);
});
test("custom widget public procedures remain available", () => {
expect(Object.keys(customWidgetRouter["_def"].procedures)).toEqual(
expect.arrayContaining([
"list",
"get",
"available",
"create",
"update",
"toggleEnabled",
"delete",
"duplicate",
"export",
"import",
"schema",
"validate",
"readTemplate",
"writeTemplate",
"templatePatch",
"previewCreate",
"previewGet",
"previewQuery",
"previewAction",
"setPreviewLiveActions",
"previewJournal",
"secretSet",
"sourceConfigure",
"configurationRequestUser",
"workshopSearch",
"workshopGet",
"workshopInstall",
"secretClear",
"getAuthoringPrompt",
"getSkill",
]),
);
test("executes promised public procedures without a session", async () => {
await expect(unauthenticatedCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
await expect(unauthenticatedCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
version: 2,
prompt: expect.any(String),
});
await expect(unauthenticatedCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
});
test("rejects protected and permission-gated authoring procedures without a session", async () => {
const calls = [
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
unauthenticatedCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
}),
];
for (const call of calls) {
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "UNAUTHORIZED" }));
}
});
test("enforces the dedicated permission on secret tools", async () => {
const callerWithoutSecretPermission = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "user-1", permissions: ["custom-widget-manage"] } } as never,
});
await expect(
callerWithoutSecretPermission.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
}),
).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
});
});
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN } from "../jsx/policy";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { customWidgetOptionsSchema } from "./options-schema";
import {
@@ -20,7 +21,10 @@ export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
.regex(
CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN,
"Binding names must start with a letter and contain letters, numbers, - or _",
);
export const customJsxTemplateSchema = z
.string()
@@ -77,8 +81,8 @@ export const customWidgetDefinitionSchema = z
})
.superRefine((definition, ctx) => {
validateRequests(definition, ctx);
validateCaseInsensitiveSourceIds(definition.sources, ctx);
validateTemplateRequests(definition.template, definition.requests, ctx);
validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
@@ -144,6 +148,23 @@ function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
}
}
function validateCaseInsensitiveSourceIds(sources: Definition["sources"], ctx: z.RefinementCtx) {
const seen = new Map<string, string>();
for (const sourceId of Object.keys(sources)) {
const normalized = sourceId.toLowerCase();
const existing = seen.get(normalized);
if (existing) {
ctx.addIssue({
code: "custom",
path: ["sources", sourceId],
message: `Source ID '${sourceId}' conflicts case-insensitively with '${existing}'`,
});
} else {
seen.set(normalized, sourceId);
}
}
}
function validateTemplateRequests(template: string, requests: Record<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
@@ -169,15 +190,6 @@ function validateTemplateRequests(template: string, requests: Record<string, Cus
}
}
function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
const inputName = match[1] ?? match[2];
if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
}
}
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
@@ -7,7 +7,7 @@ import {
import type { AstNode } from "./analyzer-ast";
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
import { closestCustomJsxComponentName, customJsxTagName, isSafeLiteralCustomJsxUrl } from "./analyzer-language";
import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN, CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
interface AnalyzerJsxContext {
@@ -75,6 +75,7 @@ export function analyzeCustomJsxElement(
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
}
if (attributeName === "bind") analyzeBindAttribute(attribute, context);
analyzeAttributeValue(resolvedName, attributeName, attribute, depth, bindings, context);
}
@@ -88,6 +89,15 @@ export function analyzeCustomJsxElement(
});
}
function analyzeBindAttribute(attribute: AstNode, context: AnalyzerJsxContext): void {
const value = nodeOf(attribute.value);
if (value?.type !== "Literal" || typeof value.value !== "string") {
context.add(attribute, "bind must use a literal input name");
} else if (!CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN.test(value.value)) {
context.add(attribute, `Invalid bind input name '${value.value}'`);
}
}
function analyzeAttributeValue(
componentName: string | null,
attributeName: string,
+5 -12
View File
@@ -23,6 +23,7 @@ import {
CUSTOM_JSX_CALLBACK_METHODS,
CUSTOM_JSX_SAFE_VALUE_METHODS,
} from "./safe-language-policy";
import { isSafeRegexLiteral } from "./regex-policy";
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
@@ -153,7 +154,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
case "Literal":
if (typeof node.value === "bigint") add(node, "BIGINT_NOT_SUPPORTED: Use Number for widget values");
if (node.regex !== undefined && !isSafeRegexLiteral(node.regex))
add(node, "UNSAFE_REGEX: Use a bounded regex without lookbehind, backreferences, or nested quantifiers");
add(node, "UNSAFE_REGEX: Use a conservative regex without repeated alternatives or variable quantifiers");
return;
case "Identifier": {
const name = String(node.name ?? "");
@@ -232,7 +233,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
}
case "CallExpression": {
const callee = nodeOf(node.callee);
const arguments_ = nodesOf(node.arguments);
const callArguments = nodesOf(node.arguments);
if (node.optional) {
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
}
@@ -251,7 +252,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called");
}
if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) {
const callback = arguments_[0];
const callback = callArguments[0];
if (!callback) {
add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`);
} else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") {
@@ -260,7 +261,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
}
}
arguments_.forEach((argument, argumentIndex) => {
callArguments.forEach((argument, argumentIndex) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
else if (
argument.type === "ArrowFunctionExpression" &&
@@ -318,11 +319,3 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return diagnostics;
}
function isSafeRegexLiteral(regex: unknown): boolean {
if (!regex || typeof regex !== "object") return false;
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
if (typeof pattern !== "string" || pattern.length > 128 || typeof flags !== "string" || /[^gimsu]/u.test(flags))
return false;
return !/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(pattern);
}
+78 -7
View File
@@ -1,14 +1,85 @@
import { createSafeCallable } from "./safe-bindings";
import { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export const CUSTOM_JSX_DATA_LIMITS = Object.freeze({
depth: 32,
nodes: 50_000,
stringLength: 200_000,
});
interface SanitizeTask {
value: unknown;
depth: number;
assign(value: unknown): void;
}
function sanitizeData(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
safe[key] = sanitizeData(child);
let result: unknown;
let nodes = 0;
const seen = new WeakSet<object>();
const tasks: SanitizeTask[] = [{ value, depth: 0, assign: (sanitized) => (result = sanitized) }];
while (tasks.length > 0) {
const task = tasks.pop();
if (!task) break;
if (++nodes > CUSTOM_JSX_DATA_LIMITS.nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
if (task.depth > CUSTOM_JSX_DATA_LIMITS.depth) {
throw new SafeJsxBudgetError(`Response data exceeded the depth limit (${CUSTOM_JSX_DATA_LIMITS.depth})`);
}
if (typeof task.value === "string" && task.value.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
if (task.value === null || typeof task.value !== "object") {
task.assign(task.value);
continue;
}
if (seen.has(task.value)) throw new SafeJsxError("Response data must not contain circular or repeated objects");
seen.add(task.value);
if (Array.isArray(task.value)) {
if (task.value.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe = Array.from<unknown>({ length: task.value.length });
task.assign(safe);
for (let index = task.value.length - 1; index >= 0; index -= 1) {
if (!Object.hasOwn(task.value, index)) continue;
tasks.push({
value: task.value[index],
depth: task.depth + 1,
assign: (sanitized) => (safe[index] = sanitized),
});
}
continue;
}
const entries = Object.entries(task.value);
if (entries.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe: Record<string, unknown> = Object.create(null);
task.assign(safe);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index] as [string, unknown];
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
if (key.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
tasks.push({
value: child,
depth: task.depth + 1,
assign: (sanitized) => (safe[key] = sanitized),
});
}
}
return safe;
return result;
}
const safeMath = Object.freeze(
@@ -11,6 +11,7 @@ import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets }
import { normalizeCustomJsxText, parseCustomJsxTemplate } from "./interpreter-parser";
import { normalizedProperty, ownProperty } from "./safe-properties";
import { CUSTOM_JSX_CALLBACK_METHODS } from "./safe-language-policy";
import { isSafeRegexLiteral } from "./regex-policy";
export { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export type { SafeJsxBudgets } from "./interpreter-foundation";
@@ -51,16 +52,10 @@ class Interpreter {
case "Literal":
if (node.regex !== undefined) {
const regex = node.regex as { pattern?: unknown; flags?: unknown };
if (
typeof regex.pattern !== "string" ||
regex.pattern.length > 128 ||
typeof regex.flags !== "string" ||
/[^gimsu]/u.test(regex.flags) ||
/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(regex.pattern)
) {
if (!isSafeRegexLiteral(regex)) {
throw new SafeJsxError("UNSAFE_REGEX: Invalid regular expression literal");
}
return new RegExp(regex.pattern, regex.flags);
return new RegExp(regex.pattern as string, regex.flags as string);
}
if (typeof node.value === "bigint")
throw new SafeJsxError("BIGINT_NOT_SUPPORTED: Use Number for widget values");
@@ -43,7 +43,12 @@ export function emitJsxElement(
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
if (attribute.type === "JSXSpreadAttribute") {
const spread = context.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
if (spread && typeof spread === "object" && !Array.isArray(spread)) Object.assign(rawProps, spread);
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
if (Object.hasOwn(spread, "bind")) {
throw new SafeJsxError("BIND_SPREAD_NOT_ALLOWED: The bind prop must be an explicit literal JSX attribute");
}
Object.assign(rawProps, spread);
}
continue;
}
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
@@ -7,6 +7,8 @@ export const CUSTOM_JSX_LIMITS = Object.freeze({
stringLength: 200_000,
});
export const CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/u;
export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
"__proto__",
"apply",
@@ -0,0 +1,144 @@
interface RegexGroupState {
atomCount: number;
containsAlternation: boolean;
containsQuantifier: boolean;
}
interface RegexQuantifier {
nextIndex: number;
variable: boolean;
}
const MAX_REGEX_PATTERN_LENGTH = 128;
const MAX_EXACT_REPETITION = 1_000;
/**
* JavaScript's regular-expression engine can backtrack exponentially. Keep authored
* expressions in a deliberately small, linear-time subset: no lookarounds or
* backreferences, no repeated alternatives/groups, and at most one quantifier that
* can consume a variable number of characters in the whole expression.
*/
export function isSafeRegexLiteral(regex: unknown): boolean {
if (!regex || typeof regex !== "object") return false;
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
if (
typeof pattern !== "string" ||
pattern.length > MAX_REGEX_PATTERN_LENGTH ||
typeof flags !== "string" ||
/[^gimsu]/u.test(flags)
) {
return false;
}
return isSafeRegexPattern(pattern);
}
function isSafeRegexPattern(pattern: string): boolean {
const groups: RegexGroupState[] = [createGroupState()];
let variableQuantifiers = 0;
let index = 0;
const consumeAtom = (nested?: RegexGroupState): boolean => {
const parent = groups.at(-1);
if (!parent) return false;
parent.atomCount += 1;
if (nested) {
parent.containsAlternation ||= nested.containsAlternation;
parent.containsQuantifier ||= nested.containsQuantifier;
}
const quantifier = readQuantifier(pattern, index);
if (!quantifier) return true;
if (quantifier.nextIndex > pattern.length) return false;
if (nested && (nested.atomCount === 0 || nested.containsAlternation || nested.containsQuantifier)) return false;
parent.containsQuantifier = true;
if (quantifier.variable && ++variableQuantifiers > 1) return false;
index = quantifier.nextIndex;
return true;
};
while (index < pattern.length) {
const character = pattern[index];
if (character === "\\") {
const escaped = pattern[index + 1];
if (escaped === undefined || /[1-9k]/u.test(escaped)) return false;
index += 2;
if (!consumeAtom()) return false;
continue;
}
if (character === "[") {
index += 1;
let closed = false;
while (index < pattern.length) {
if (pattern[index] === "\\") index += 2;
else {
const classCharacter = pattern[index++];
if (classCharacter === "]") {
closed = true;
break;
}
}
}
if (!closed || !consumeAtom()) return false;
continue;
}
if (character === "(") {
if (pattern[index + 1] === "?") return false;
groups.push(createGroupState());
index += 1;
continue;
}
if (character === ")") {
if (groups.length === 1) return false;
const nested = groups.pop();
index += 1;
if (!nested || !consumeAtom(nested)) return false;
continue;
}
if (character === "|") {
const current = groups.at(-1);
if (!current) return false;
current.containsAlternation = true;
index += 1;
continue;
}
if (character === "^" || character === "$") {
index += 1;
continue;
}
if (character === "*" || character === "+" || character === "?" || character === "{") {
// A valid quantifier is consumed with its atom. Reaching one here means it is
// malformed or is an unsupported literal brace construct.
return false;
}
index += 1;
if (!consumeAtom()) return false;
}
return groups.length === 1;
}
function createGroupState(): RegexGroupState {
return { atomCount: 0, containsAlternation: false, containsQuantifier: false };
}
function readQuantifier(pattern: string, index: number): RegexQuantifier | null {
const character = pattern[index];
if (character === "*" || character === "+" || character === "?") {
const nextIndex = pattern[index + 1] === "?" ? index + 2 : index + 1;
return { nextIndex, variable: true };
}
if (character !== "{") return null;
const match = /^\{(\d+)(?:,(\d*))?\}/u.exec(pattern.slice(index));
if (!match) return null;
const minimum = Number(match[1]);
const maximum = match[2] === undefined || match[2] === "" ? undefined : Number(match[2]);
if (
!Number.isSafeInteger(minimum) ||
minimum > MAX_EXACT_REPETITION ||
(maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum > MAX_EXACT_REPETITION))
) {
return { nextIndex: pattern.length + 1, variable: true };
}
let nextIndex = index + match[0].length;
if (pattern[nextIndex] === "?") nextIndex += 1;
return { nextIndex, variable: match[2] !== undefined && maximum !== minimum };
}
@@ -162,19 +162,24 @@ function CustomJsxRendererSession({
},
[inputTypes],
);
const bindings = useMemo(
() => ({ ...createBindings(data), status, options, inputs }),
[createBindings, data, inputs, options, status],
);
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
const rendered = useMemo(() => {
try {
return { ...renderSafeJsx({ template, components, bindings }), error: null };
const bindings = { ...createBindings(data), status, options, inputs };
return {
...renderSafeJsx({ template, components, bindings }),
boundaryKey: createBoundaryKey(template, bindings),
error: null,
};
} catch (error) {
return { node: null, warnings: [], error: error instanceof Error ? error : new Error(String(error)) };
return {
node: null,
warnings: [],
boundaryKey: `${template.length}:error`,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}, [bindings, components, template]);
useEffect(() => setParseErrors([]), [bindings, template]);
}, [components, createBindings, data, inputs, options, status, template]);
useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]);
const handleError = useCallback(
(error: Error) =>
setParseErrors((current) => {
@@ -201,7 +206,7 @@ function CustomJsxRendererSession({
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary resetKey={boundaryKey} onError={handleError}>
<RendererErrorBoundary resetKey={rendered.boundaryKey} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxInputsProvider>
@@ -34,6 +34,11 @@ export interface SubFetchProps {
trigger?: "auto" | "manual";
}
// Browser timers use signed 32-bit millisecond delays. Whole-second refresh
// intervals are capped at the largest value that remains below that boundary.
export const MAX_REFRESH_INTERVAL_SECONDS = 2_147_483;
export const MAX_REFRESH_INTERVAL_MS = MAX_REFRESH_INTERVAL_SECONDS * 1_000;
export function SubFetch(props: SubFetchProps) {
const { itemId, previewSessionId, queriesDisabled, port, messages, setQueryState } = useCustomWidgetRuntime();
const [manualRun, setManualRun] = useState(false);
@@ -155,8 +160,9 @@ export function SubFetch(props: SubFetchProps) {
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
}
function normalizeRefreshInterval(value: number | undefined): number | false {
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
export function normalizeRefreshInterval(value: number | undefined): number | false {
if (!value || !Number.isFinite(value) || value <= 0) return false;
return Math.max(1_000, Math.min(value, MAX_REFRESH_INTERVAL_SECONDS) * 1_000);
}
function renderContent(props: SubFetchProps, data: unknown, metadata: SubFetchMetadata) {
@@ -1,6 +1,7 @@
export type CustomWidgetDomainErrorCode =
| "BAD_GATEWAY"
| "BAD_REQUEST"
| "CONFLICT"
| "FORBIDDEN"
| "INTERNAL_SERVER_ERROR"
| "NOT_FOUND"
+2 -1
View File
@@ -6,6 +6,7 @@ export {
classifyAddress,
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
MAX_REQUEST_DURATION_MS,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
@@ -15,7 +16,7 @@ export {
validateCustomWidgetUrl,
} from "./request-executor";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
export { CustomWidgetRequestLimiter } from "./request-limits";
export { CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, CustomWidgetRequestLimiter } from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
export type {
@@ -12,6 +12,7 @@ import { CustomWidgetDomainError } from "./errors";
const SESSION_TTL_MS = 10 * 60_000;
const MAX_JOURNAL_ENTRIES = 50;
const MAX_UPDATE_ATTEMPTS = 8;
const encryptedSecretSchema = z.object({
sourceId: z.string(),
@@ -22,6 +23,7 @@ const encryptedSecretSchema = z.object({
const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
revision: z.number().int().nonnegative(),
expiresAt: z.number(),
sources: customWidgetSourcesSchema,
secrets: z.array(encryptedSecretSchema),
@@ -62,6 +64,7 @@ export interface CreatePreviewSessionInput {
export interface PreviewSessionStore {
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
getSession(id: string): Promise<unknown>;
deleteSession(id: string): Promise<void>;
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
@@ -96,6 +99,7 @@ export class CustomWidgetPreviewSessionService {
const session: CustomWidgetPreviewSession = {
id: this.options.createId(),
userId: input.userId,
revision: 0,
expiresAt: this.now() + SESSION_TTL_MS,
sources: input.sources,
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
@@ -114,17 +118,19 @@ export class CustomWidgetPreviewSessionService {
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
const parsed = sessionSchema.safeParse(parseStoredValue(candidate));
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= this.now()) {
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
if (this.options.store) await this.options.store.deleteSession(id);
else this.sessions.delete(id);
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
if (parsed.data.userId !== userId) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
return parsed.data;
}
public async setLiveActions(id: string, userId: string, enabled: boolean) {
const session = { ...(await this.get(id, userId)), liveActions: enabled };
await this.save(session);
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
return { id, expiresAt: session.expiresAt, liveActions: enabled };
}
@@ -160,23 +166,23 @@ export class CustomWidgetPreviewSessionService {
userId: string,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const session = await this.get(id, userId);
const sourceIds = new Set(Object.keys(session.sources));
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
const next = {
...session,
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
await this.save(next);
const next = await this.update(id, userId, (session) => {
const sourceIds = new Set(Object.keys(session.sources));
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
...session,
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
});
return { id, expiresAt: next.expiresAt };
}
@@ -187,33 +193,53 @@ export class CustomWidgetPreviewSessionService {
source: CustomWidgetSource,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const session = await this.get(id, userId);
const currentSource = session.sources[sourceId];
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
const next = {
...session,
sources: {
...session.sources,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
await this.save(next);
const next = await this.update(id, userId, (session) => {
const currentSource = session.sources[sourceId];
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
...session,
sources: {
...session.sources,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
});
return { id, expiresAt: next.expiresAt };
}
private async update(
id: string,
userId: string,
mutate: (session: CustomWidgetPreviewSession) => CustomWidgetPreviewSession,
) {
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS; attempt += 1) {
const current = await this.get(id, userId);
const next = { ...mutate(current), revision: current.revision + 1 };
const ttlMs = Math.max(0, next.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
if (this.options.store) {
if (await this.options.store.compareAndSwapSession(id, current.revision, next, ttlMs)) return next;
} else if (this.sessions.get(id)?.revision === current.revision) {
this.sessions.set(id, next);
return next;
}
}
throw new CustomWidgetDomainError({ code: "CONFLICT", message: "Preview session changed too many times; retry" });
}
private async save(session: CustomWidgetPreviewSession): Promise<void> {
const ttlMs = Math.max(0, session.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
@@ -32,6 +32,7 @@ export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
export const MAX_REQUEST_DURATION_MS = 45_000;
export interface CustomWidgetAuthConfig {
type: string;
@@ -64,6 +65,29 @@ const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
try {
return await performRequestWithinDeadline(input, controller.signal);
} catch (error) {
if (controller.signal.aborted) {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "External request exceeded the total time limit",
cause: error,
});
}
if (error instanceof CustomWidgetDomainError) throw error;
throw error;
} finally {
clearTimeout(timeout);
}
}
async function performRequestWithinDeadline(
input: CustomWidgetHttpRequest,
deadlineSignal: AbortSignal,
): Promise<CustomWidgetHttpResponse> {
assertRequest(input);
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
@@ -86,7 +110,7 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
method: currentMethod,
headers,
body: currentBody,
signal: controller.signal,
signal: AbortSignal.any([deadlineSignal, controller.signal]),
});
} catch (error) {
await dispatcher.close();
@@ -1,9 +1,12 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { CustomWidgetDomainError } from "./errors";
const WINDOW_MS = 60_000;
const CONCURRENCY_TTL_MS = 30_000;
// A query may legally follow three redirects with a ten-second timeout per hop.
// Keep the lease above that total and identify every acquisition so a late
// release can never remove capacity owned by a newer request.
export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000;
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
type RequestCategory = keyof typeof CATEGORY_LIMITS;
@@ -16,8 +19,8 @@ export interface RequestLimitInput {
export interface RequestLimitStore {
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
acquireConcurrency(key: string, limit: number, ttlMs: number): Promise<boolean>;
releaseConcurrency(key: string): Promise<void>;
acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number): Promise<boolean>;
releaseConcurrency(key: string, ownerId: string): Promise<void>;
}
export interface RequestLimiterOptions {
@@ -34,8 +37,8 @@ export class CustomWidgetRequestLimiter {
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
const identity = input.userId ?? "anonymous";
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
const userKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
const userKey = `custom-widget:concurrency:v2:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:v2:definition:${hashKey(input.definitionId)}`;
if (!this.options.store) return this.acquireLocal(rateKey, userKey, definitionKey, input.category);
try {
return await this.acquireStored(rateKey, userKey, definitionKey, input.category, this.options.store);
@@ -76,17 +79,22 @@ export class CustomWidgetRequestLimiter {
category: RequestCategory,
store: RequestLimitStore,
) {
const ownerId = randomUUID();
const rate = await store.incrementRate(rateKey, WINDOW_MS);
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
if (!(await store.acquireConcurrency(userKey, 4, CONCURRENCY_TTL_MS))) throwLimit(1_000);
if (!(await store.acquireConcurrency(userKey, ownerId, 4, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
throwLimit(1_000);
}
try {
if (!(await store.acquireConcurrency(definitionKey, 8, CONCURRENCY_TTL_MS))) throwLimit(1_000);
if (!(await store.acquireConcurrency(definitionKey, ownerId, 8, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
throwLimit(1_000);
}
} catch (error) {
await store.releaseConcurrency(userKey);
await store.releaseConcurrency(userKey, ownerId);
throw error;
}
return async () => {
await Promise.all([store.releaseConcurrency(userKey), store.releaseConcurrency(definitionKey)]);
await Promise.all([store.releaseConcurrency(userKey, ownerId), store.releaseConcurrency(definitionKey, ownerId)]);
};
}
@@ -3,7 +3,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { createCustomJsxBindings } from "../jsx/bindings";
import { createCustomJsxBindings, CUSTOM_JSX_DATA_LIMITS } from "../jsx/bindings";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
@@ -221,6 +221,37 @@ describe("shared Custom JSX policy", () => {
expect(diagnostics.some(({ message }) => message.includes("Prop 'bind'"))).toBe(false);
});
test("validates explicit bind attributes through the JSX AST", () => {
expect(
validateCustomJsxTemplate('<TextInput bind="search" />').filter(({ severity }) => severity === "error"),
).toEqual([]);
expect(validateCustomJsxTemplate("<TextInput bind={data.name} />")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("literal input name") }),
]),
);
expect(validateCustomJsxTemplate('<TextInput bind="not valid" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Invalid bind input name") }),
]),
);
expect(
validateCustomJsxTemplate('<Text>{"<TextInput bind={data.name} />"}</Text>').some(({ message }) =>
message.includes("literal input name"),
),
).toBe(false);
});
test("rejects bind values introduced by JSX spreads at the emitter boundary", () => {
expect(() =>
renderSafeJsx({
template: "<TextInput {...data.props} />",
components: { TextInput: (() => null) as never },
bindings: { data: { props: { bind: "remote-name", label: "Unsafe" } } },
}),
).toThrow("BIND_SPREAD_NOT_ALLOWED");
});
test("provides closest component and binding diagnostics", () => {
expect(validateCustomJsxTemplate("<Stak />")[0]?.message).toMatch(/UNKNOWN_COMPONENT.*Stack/u);
expect(validateCustomJsxTemplate('<Text bind="search" />')).toEqual(
@@ -305,9 +336,34 @@ describe("shared Custom JSX policy", () => {
expect(renderToStaticMarkup(rendered.node)).toContain("Bulba-saur-");
});
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/"])("rejects unsafe regex %s", (pattern) => {
expect(validateCustomJsxTemplate(`<Text>{${pattern}.test(data.name)}</Text>`)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/", "/^(a|aa)+$/", "/^(a|a?)+$/", "/^a+a+$/", "/^(\\w+\\s?)*$/"])(
"rejects unsafe regex %s in analysis and at runtime",
(pattern) => {
const template = `<Text>{${pattern}.test(data.name)}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
);
expect(() =>
renderSafeJsx({ template, components, bindings: { data: { name: "a".repeat(10_000) + "!" } } }),
).toThrow("UNSAFE_REGEX");
},
);
test("allows a single unambiguous variable regex quantifier", () => {
const template = '<Text>{/^(ab)+$/.test(data.name) ? "yes" : "no"}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() => renderSafeJsx({ template, components, bindings: { data: { name: "abab" } } })).not.toThrow();
});
test("bounds response data depth, node count, and string length without recursive traversal", () => {
let deeplyNested: unknown = null;
for (let depth = 0; depth <= CUSTOM_JSX_DATA_LIMITS.depth; depth += 1) deeplyNested = { child: deeplyNested };
expect(() => createCustomJsxBindings(deeplyNested)).toThrow(/depth limit/u);
expect(() => createCustomJsxBindings(Array.from({ length: CUSTOM_JSX_DATA_LIMITS.nodes }).fill(null))).toThrow(
/node limit/u,
);
expect(() => createCustomJsxBindings("x".repeat(CUSTOM_JSX_DATA_LIMITS.stringLength + 1))).toThrow(
/string length limit/u,
);
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { customWidgetDefinitionSchema } from "../core";
import { CustomWidgetPreviewSessionService } from "../server";
import type { PreviewSessionStore } from "../server";
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
@@ -109,4 +110,83 @@ describe("preview sessions", () => {
),
).rejects.toThrow("authentication changed");
});
it("does not delete a preview when a different user looks it up", async () => {
const values = new Map<string, unknown>();
const store: PreviewSessionStore = {
saveSession: async (id, value) => {
values.set(id, value);
},
compareAndSwapSession: async (id, revision, value) => {
const current = values.get(id) as { revision?: number } | undefined;
if (current?.revision !== revision) return false;
values.set(id, value);
return true;
},
getSession: async (id) => values.get(id),
deleteSession: async (id) => {
values.delete(id);
},
appendJournal: async () => undefined,
getJournal: async () => [],
};
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
encrypt: String,
decrypt: String,
store,
});
await service.create({
userId: "owner",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await expect(service.get("session", "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(service.get("session", "owner")).resolves.toMatchObject({ id: "session" });
});
it("retries concurrent mutations without losing source, secret, or action state", async () => {
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
});
await service.create({
userId: "owner",
sources: {
...definition.sources,
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "basic" },
},
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await Promise.all([
service.setLiveActions("session", "owner", true),
service.setSecrets("session", "owner", [{ sourceId: "secondary", kind: "username", value: "operator" }]),
service.configureSource(
"session",
"owner",
"default",
{ baseUrl: "https://api.example.com", networkScope: "public", auth: "bearer" },
[{ sourceId: "default", kind: "apiKey", value: "token" }],
),
]);
const session = await service.get("session", "owner");
expect(session.liveActions).toBe(true);
expect(session.sources.default?.baseUrl).toBe("https://api.example.com");
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "token" }]);
expect(service.getSecrets(session, "secondary")).toEqual([{ kind: "username", value: "operator" }]);
});
});
@@ -1,11 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { CustomWidgetRequestLimiter } from "../server";
import {
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
CustomWidgetRequestLimiter,
MAX_REQUEST_DURATION_MS,
} from "../server";
import type { RequestLimitStore } from "../server";
const input = { category: "action", userId: "user", itemId: "item", definitionId: "definition" } as const;
describe("custom widget request limits", () => {
it("keeps owned concurrency leases beyond the total request deadline", () => {
expect(CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS).toBeGreaterThan(MAX_REQUEST_DURATION_MS);
});
it("enforces local per-user/item concurrency and releases capacity", async () => {
const limiter = new CustomWidgetRequestLimiter();
const releases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(input)));
@@ -39,4 +47,31 @@ describe("custom widget request limits", () => {
});
expect(store.releaseConcurrency).toHaveBeenCalledOnce();
});
it("releases only the concurrency lease owned by the request", async () => {
const owners = new Set<string>();
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 1, retryAfterMs: 60_000 })),
acquireConcurrency: vi.fn(async (_key, ownerId) => {
owners.add(ownerId);
return true;
}),
releaseConcurrency: vi.fn(async (_key, ownerId) => {
owners.delete(ownerId);
}),
};
const releaseFirst = await new CustomWidgetRequestLimiter({ store }).acquire(input);
const firstOwner = [...owners][0];
expect(store.acquireConcurrency).toHaveBeenCalledWith(
expect.any(String),
firstOwner,
expect.any(Number),
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
);
await releaseFirst();
expect(firstOwner).toBeTypeOf("string");
expect(store.releaseConcurrency).toHaveBeenCalledWith(expect.any(String), firstOwner);
expect(owners).not.toContain(firstOwner);
});
});
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
@@ -106,6 +107,31 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("clamps refresh intervals below the browser timer overflow boundary", () => {
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS + 1)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(normalizeRefreshInterval(Number.MAX_VALUE)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(MAX_REFRESH_INTERVAL_MS).toBeLessThan(2 ** 31 - 1);
});
it("routes binding-construction failures through the runtime error display", async () => {
await render(
<CustomJsxRenderer
template="<Text>Safe</Text>"
data={{}}
components={{ Text: (() => null) as never }}
createBindings={() => {
throw new RangeError("Response data exceeded the depth limit");
}}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
expect(host.textContent).toContain("RUNTIME_RENDER_ERROR");
expect(host.textContent).toContain("Response data exceeded the depth limit");
});
it("renders common safe collection and number formatting operations", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
@@ -162,9 +188,7 @@ describe("Custom Widget runtime ports", () => {
await render(
<>
<button name="shared" role="radio" aria-checked="false" type="button">
Outside
</button>
<input aria-label="Outside" name="shared" type="radio" />
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
@@ -179,7 +203,8 @@ describe("Custom Widget runtime ports", () => {
);
await settle();
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
const outside = host.querySelector<HTMLInputElement>('input[type="radio"]');
const [inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
expect(outside?.name).toBe("shared");
expect(inside?.name).toMatch(/^custom-widget-/u);
expect(inside?.name).not.toBe(outside?.name);
@@ -53,6 +53,27 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
it("rejects source IDs that collide case-insensitively", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
sources: {
Api: { baseUrl: "https://one.example.com", networkScope: "public", auth: "none" },
api: { baseUrl: "https://two.example.com", networkScope: "public", auth: "none" },
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["sources", "api"],
message: expect.stringContaining("conflicts case-insensitively"),
}),
]),
);
}
});
it("rejects invocation params on load and unknown options", () => {
const loadParam = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -7,7 +7,40 @@ import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
import { CustomWidgetCodeEditor, PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
import type { CustomWidgetEditorMessages } from "../workbench";
vi.mock("../workbench/direct-code-mirror", () => ({
default: ({ id, label, labelledBy }: { id: string; label: string; labelledBy: string }) => (
<textarea id={id} aria-label={label} aria-labelledby={labelledBy} />
),
}));
const editorMessages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal",
fullTab: "Full",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Ready",
position: ({ line, column }) => `${line}:${column}`,
characters: (count) => `${count}`,
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.code,
};
let root: Root;
let host: HTMLDivElement;
@@ -39,6 +72,25 @@ async function render(node: ReactNode) {
}
describe("Custom Widget workbench UI", () => {
test("associates the visible label with the editable CodeMirror control", async () => {
await render(
<CustomWidgetCodeEditor
id="template-editor"
label="Template"
language="jsx"
value="<Text>Hello</Text>"
messages={editorMessages}
onChange={vi.fn()}
/>,
);
await act(async () => Promise.resolve());
const label = host.querySelector('#template-editor-label[for="template-editor"]');
expect(label).not.toBeNull();
expect(host.querySelector("#template-editor")?.getAttribute("aria-label")).toBe("Template");
expect(host.querySelector("#template-editor")?.getAttribute("aria-labelledby")).toBe("template-editor-label");
}, 15_000);
test("renders response data through the Mantine Tree with accessible path actions", async () => {
await render(
<ResponseTree
@@ -100,7 +100,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
description={props.description}
error={props.error}
required={props.required}
labelProps={{ htmlFor: props.id }}
labelProps={{ id: `${props.id}-label`, htmlFor: props.id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
@@ -152,7 +152,8 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
{editorReady ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
id={props.id}
labelledBy={`${props.id}-label`}
value={props.value}
language={props.language}
diagnostics={diagnostics}
@@ -24,6 +24,7 @@ interface DirectCodeMirrorProps {
completions: Completion[];
id: string;
label: string;
labelledBy: string;
placeholder?: string;
height: string;
theme: "light" | "dark";
@@ -121,7 +122,7 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
};
}),
),
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label, "aria-labelledby": props.labelledBy }),
EditorView.editable.of(!props.readOnly),
EditorState.readOnly.of(props.readOnly),
keymap.of(props.readOnly ? [] : [indentWithTab]),
@@ -1,5 +1,8 @@
DROP TABLE `custom_widget_secret`;--> statement-breakpoint
DROP TABLE `custom_widget_definition`;--> statement-breakpoint
RENAME TABLE `custom_widget_definition` TO `legacy_custom_widget_definition`, `custom_widget_secret` TO `legacy_custom_widget_secret`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_definition` DROP FOREIGN KEY `custom_widget_definition_creator_id_user_id_fk`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_definition` ADD CONSTRAINT `legacy_cw_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_secret` DROP FOREIGN KEY `cw_secret_definition_id_cw_definition_id_fk`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_secret` ADD CONSTRAINT `legacy_cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `legacy_custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE TABLE `custom_widget_definition` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
@@ -24,4 +27,4 @@ CREATE TABLE `custom_widget_secret` (
CONSTRAINT `custom_widget_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
);--> statement-breakpoint
ALTER TABLE `custom_widget_definition` ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_secret` ADD CONSTRAINT `cw_secret_definition_id_cw_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;
ALTER TABLE `custom_widget_secret` ADD CONSTRAINT `cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;
@@ -534,6 +534,196 @@
"uniqueConstraints": {},
"checkConstraint": {}
},
"legacy_custom_widget_definition": {
"name": "legacy_custom_widget_definition",
"columns": {
"id": {
"name": "id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_type": {
"name": "auth_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"method": {
"name": "method",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_type": {
"name": "display_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"json\": {}}')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_definition_creator_id_user_id_fk": {
"name": "legacy_cw_definition_creator_id_user_id_fk",
"tableFrom": "legacy_custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_definition_id": {
"name": "legacy_custom_widget_definition_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"legacy_custom_widget_secret": {
"name": "legacy_custom_widget_secret",
"columns": {
"kind": {
"name": "kind",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"definition_id": {
"name": "definition_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_secret_definition_id_fk": {
"name": "legacy_cw_secret_definition_id_fk",
"tableFrom": "legacy_custom_widget_secret",
"tableTo": "legacy_custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_secret_definition_id_kind_pk": {
"name": "legacy_custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"custom_widget_definition": {
"name": "custom_widget_definition",
"columns": {
@@ -687,8 +877,8 @@
},
"indexes": {},
"foreignKeys": {
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"cw_secret_definition_id_fk": {
"name": "cw_secret_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
@@ -1,5 +1,8 @@
DROP TABLE "custom_widget_secret";--> statement-breakpoint
DROP TABLE "custom_widget_definition";--> statement-breakpoint
ALTER TABLE "custom_widget_definition" RENAME TO "legacy_custom_widget_definition";--> statement-breakpoint
ALTER TABLE "custom_widget_secret" RENAME TO "legacy_custom_widget_secret";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_definition" RENAME CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" TO "legacy_cw_definition_creator_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk" TO "legacy_cw_secret_definition_id_fk";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_kind_pk" TO "legacy_custom_widget_secret_definition_id_kind_pk";--> statement-breakpoint
CREATE TABLE "custom_widget_definition" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
@@ -23,4 +26,4 @@ CREATE TABLE "custom_widget_secret" (
CONSTRAINT "custom_widget_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
);--> statement-breakpoint
ALTER TABLE "custom_widget_definition" ADD CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_widget_secret" ADD CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_definition"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "custom_widget_secret" ADD CONSTRAINT "cw_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_definition"("id") ON DELETE cascade ON UPDATE no action;
@@ -498,6 +498,178 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.legacy_custom_widget_definition": {
"name": "legacy_custom_widget_definition",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(64)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_type": {
"name": "auth_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"method": {
"name": "method",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_type": {
"name": "display_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"json\": {}}'"
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_definition_creator_id_user_id_fk": {
"name": "legacy_cw_definition_creator_id_user_id_fk",
"tableFrom": "legacy_custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.legacy_custom_widget_secret": {
"name": "legacy_custom_widget_secret",
"schema": "",
"columns": {
"kind": {
"name": "kind",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"definition_id": {
"name": "definition_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_secret_definition_id_fk": {
"name": "legacy_cw_secret_definition_id_fk",
"tableFrom": "legacy_custom_widget_secret",
"tableTo": "legacy_custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_secret_definition_id_kind_pk": {
"name": "legacy_custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.custom_widget_definition": {
"name": "custom_widget_definition",
"schema": "",
@@ -633,8 +805,8 @@
},
"indexes": {},
"foreignKeys": {
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"cw_secret_definition_id_fk": {
"name": "cw_secret_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
@@ -1,6 +1,6 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
DROP TABLE `custom_widget_secret`;--> statement-breakpoint
DROP TABLE `custom_widget_definition`;--> statement-breakpoint
ALTER TABLE `custom_widget_definition` RENAME TO `legacy_custom_widget_definition`;--> statement-breakpoint
ALTER TABLE `custom_widget_secret` RENAME TO `legacy_custom_widget_secret`;--> statement-breakpoint
CREATE TABLE `custom_widget_definition` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
@@ -515,6 +515,191 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_custom_widget_definition": {
"name": "legacy_custom_widget_definition",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auth_type": {
"name": "auth_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"method": {
"name": "method",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_type": {
"name": "display_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"json\": {}}'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"creator_id": {
"name": "creator_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_definition_creator_id_user_id_fk": {
"name": "legacy_cw_definition_creator_id_user_id_fk",
"tableFrom": "legacy_custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_custom_widget_secret": {
"name": "legacy_custom_widget_secret",
"columns": {
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"definition_id": {
"name": "definition_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_secret_definition_id_fk": {
"name": "legacy_cw_secret_definition_id_fk",
"tableFrom": "legacy_custom_widget_secret",
"tableTo": "legacy_custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_secret_definition_id_kind_pk": {
"columns": ["definition_id", "kind"],
"name": "legacy_custom_widget_secret_definition_id_kind_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_widget_definition": {
"name": "custom_widget_definition",
"columns": {
@@ -663,8 +848,8 @@
},
"indexes": {},
"foreignKeys": {
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"cw_secret_definition_id_fk": {
"name": "cw_secret_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
+4
View File
@@ -51,6 +51,8 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
legacyCustomWidgetDefinitions,
legacyCustomWidgetSecrets,
widgetSecrets,
} = schema;
@@ -62,3 +64,5 @@ export type Integration = InferSelectModel<typeof schema.integrations>;
export type IntegrationSecret = InferSelectModel<typeof schema.integrationSecrets>;
export type CustomWidgetDefinition = InferSelectModel<typeof schema.customWidgetDefinitions>;
export type CustomWidgetSecret = InferSelectModel<typeof schema.customWidgetSecrets>;
export type LegacyCustomWidgetDefinition = InferSelectModel<typeof schema.legacyCustomWidgetDefinitions>;
export type LegacyCustomWidgetSecret = InferSelectModel<typeof schema.legacyCustomWidgetSecrets>;
+74 -5
View File
@@ -6,6 +6,7 @@ import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import {
boolean,
customType,
foreignKey,
index,
int,
mysqlTable,
@@ -206,8 +207,8 @@ export const integrations = mysqlTable(
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
(table) => ({
kindIdx: index("integration__kind_idx").on(table.kind),
}),
);
@@ -500,6 +501,56 @@ export const onboarding = mysqlTable("onboarding", {
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
/**
* Read-only v1 definitions retained during the Custom JSX v2 upgrade.
* They remain separate so legacy code can never execute in the v2 runtime.
*/
export const legacyCustomWidgetDefinitions = mysqlTable(
"legacy_custom_widget_definition",
{
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
description: text(),
iconUrl: text(),
url: text().notNull(),
authType: varchar({ length: 32 }).notNull().default("none"),
headerName: varchar({ length: 256 }),
method: varchar({ length: 16 }).notNull().default("GET"),
requestBody: text(),
displayType: varchar({ length: 32 }).notNull().default("singleValue"),
displayConfig: text().default(emptySuperJSON).notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
updatedAt: timestamp({ mode: "date" }).notNull().defaultNow(),
creatorId: varchar({ length: 64 }),
},
(table) => ({
creatorFk: foreignKey({
columns: [table.creatorId],
foreignColumns: [users.id],
name: "legacy_cw_definition_creator_id_user_id_fk",
}).onDelete("set null"),
}),
);
export const legacyCustomWidgetSecrets = mysqlTable(
"legacy_custom_widget_secret",
{
kind: varchar({ length: 64 }).$type<CustomWidgetSecretKind>().notNull(),
encryptedValue: text("value").$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp().notNull(),
definitionId: varchar({ length: 64 }).notNull(),
},
(table) => ({
compoundKey: primaryKey({ columns: [table.definitionId, table.kind] }),
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [legacyCustomWidgetDefinitions.id],
name: "legacy_cw_secret_definition_id_fk",
}).onDelete("cascade"),
}),
);
export const customWidgetDefinitions = mysqlTable("custom_widget_definition", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
@@ -524,14 +575,17 @@ export const customWidgetSecrets = mysqlTable(
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
definitionId: varchar({ length: 64 })
.notNull()
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
definitionId: varchar({ length: 64 }).notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.definitionId, table.sourceId, table.kind],
}),
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [customWidgetDefinitions.id],
name: "cw_secret_definition_id_fk",
}).onDelete("cascade"),
}),
);
@@ -847,3 +901,18 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const legacyCustomWidgetDefinitionRelations = relations(legacyCustomWidgetDefinitions, ({ many, one }) => ({
secrets: many(legacyCustomWidgetSecrets),
creator: one(users, {
fields: [legacyCustomWidgetDefinitions.creatorId],
references: [users.id],
}),
}));
export const legacyCustomWidgetSecretRelations = relations(legacyCustomWidgetSecrets, ({ one }) => ({
definition: one(legacyCustomWidgetDefinitions, {
fields: [legacyCustomWidgetSecrets.definitionId],
references: [legacyCustomWidgetDefinitions.id],
}),
}));
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more