From e94b4d4bc97b8526dd9e248cf4570d301ce1388c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 22 Jul 2026 18:12:09 +0200 Subject: [PATCH] feat: configure custom widget sources --- .agents/skills/homarr-custom-widget/SKILL.md | 8 +- .../homarr-custom-widget/references/schema.md | 2 + .../references/security.md | 2 +- apps/docs/docs/management/api/index.mdx | 2 +- .../custom-widgets/agent-authoring.mdx | 21 +- .../docs/management/custom-widgets/index.mdx | 10 +- apps/docs/docs/management/mcp.mdx | 19 +- .../[token]/configuration-entry.tsx} | 28 +- .../[token]/page.tsx | 13 + .../custom-widget-secret/[token]/page.tsx | 13 - .../configuration-request/[token]/route.ts | 182 ++++ .../secret-request/[token]/route.ts | 96 -- .../custom-widget-import-dialog.tsx | 125 +-- .../components/workshop/workshop-client.ts | 9 +- .../workshop/workshop-install-button.tsx | 63 +- .../workshop/workshop-publish-modal.tsx | 41 +- packages/api/package.json | 3 +- .../api/src/custom-widget-configuration.ts | 6 + .../custom-widget/configuration-requests.ts | 104 +++ .../custom-widget/custom-widget-router.ts | 2 + .../router/custom-widget/preview-sessions.ts | 7 + .../router/custom-widget/secret-procedures.ts | 138 ++- .../router/custom-widget/secret-requests.ts | 102 --- .../custom-widget/workshop-procedures.ts | 172 ++++ .../configuration-requests.spec.ts | 32 + .../definition-persistence.spec.ts | 57 ++ .../custom-widget/secret-requests.spec.ts | 31 - packages/api/src/test/mcp.spec.ts | 12 +- packages/custom-widgets/src/core/ai-prompt.ts | 10 +- .../src/core/authoring-resources.ts | 10 +- packages/custom-widgets/src/core/index.ts | 1 + .../custom-widgets/src/core/source-setup.ts | 96 ++ .../src/server/preview-sessions.ts | 35 + .../src/test/preview-sessions.spec.ts | 43 + .../custom-widgets/src/test/schema.spec.ts | 62 ++ .../src/workbench/import-review.tsx | 19 +- .../custom-widgets/src/workbench/index.ts | 1 + .../src/workbench/source-setup.tsx | 189 ++++ packages/translation/src/lang/en.json | 24 +- packages/workshop/package.json | 3 + packages/workshop/src/backend.ts | 834 ++++++++++++++++++ packages/workshop/src/client.spec.ts | 119 ++- pnpm-lock.yaml | 9 + 43 files changed, 2361 insertions(+), 394 deletions(-) rename apps/nextjs/src/app/[locale]/{custom-widget-secret/[token]/secret-entry.tsx => custom-widget-configuration/[token]/configuration-entry.tsx} (74%) create mode 100644 apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx delete mode 100644 apps/nextjs/src/app/[locale]/custom-widget-secret/[token]/page.tsx create mode 100644 apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts delete mode 100644 apps/nextjs/src/app/api/custom-widgets/secret-request/[token]/route.ts create mode 100644 packages/api/src/custom-widget-configuration.ts create mode 100644 packages/api/src/router/custom-widget/configuration-requests.ts delete mode 100644 packages/api/src/router/custom-widget/secret-requests.ts create mode 100644 packages/api/src/router/custom-widget/workshop-procedures.ts create mode 100644 packages/api/src/router/test/custom-widget/configuration-requests.spec.ts delete mode 100644 packages/api/src/router/test/custom-widget/secret-requests.spec.ts create mode 100644 packages/custom-widgets/src/core/source-setup.ts create mode 100644 packages/custom-widgets/src/workbench/source-setup.tsx create mode 100644 packages/workshop/src/backend.ts diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md index 02b50b0ba..dbd2a962e 100644 --- a/.agents/skills/homarr-custom-widget/SKILL.md +++ b/.agents/skills/homarr-custom-widget/SKILL.md @@ -10,15 +10,19 @@ Author one widget at a time. When connected to Homarr, retrieve `homarr://custom 1. Read the API documentation. 2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`. 3. Validate, preview, test queries and simulated actions, visually inspect, and iterate. -4. Request credentials through Homarr and never repeat plaintext. +4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext. 5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Templates read `data`, `status`, `options`, and temporary `inputs`. -`SubFetch` with `trigger="manual"` renders its own load button. Its callback is `(result, meta)`; never author `onClick` or a fetch callback. +`SubFetch` with `trigger="manual"` renders its own load button. Pass a card or image as `triggerContent` with `triggerAriaLabel` when that content should launch the request. Its callback is `(result, meta)`; never author `onClick` or a fetch callback. Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards. Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session. +To install an existing widget, use `customWidget_workshopSearch`, inspect it with `customWidget_workshopGet`, then use `customWidget_workshopInstall`. Configure each self-hosted source afterward with `customWidget_sourceConfigure`, or call `customWidget_configurationRequestUser` so the user can enter the server URL and credentials directly in Homarr. + +Preview-scoped configuration expires with the preview. To persist user-entered values for a new widget, create the widget first, request configuration for its definition ID, then create a final preview with that definition ID so Homarr loads the stored credentials. + When this skill is installed from the repository, optional offline details are in `references/schema.md`, `references/runtime.md`, and `references/security.md`. MCP-only clients should use the live Homarr resources instead. diff --git a/.agents/skills/homarr-custom-widget/references/schema.md b/.agents/skills/homarr-custom-widget/references/schema.md index 9b50729bb..ad1a1b1a0 100644 --- a/.agents/skills/homarr-custom-widget/references/schema.md +++ b/.agents/skills/homarr-custom-widget/references/schema.md @@ -15,6 +15,8 @@ interface HomarrCustomWidgetV2 { Sources require `default`. A request defaults to the default source, query, GET, load, inherited auth, and view permission. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. +Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest. + Paths use `{option:name}` and `{param:name}`. Query/body data uses `{ "$option": "name" }` and `{ "$param": "name" }`. Load queries cannot use params. Runtime parameter names and primitive values are inferred from references. Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`. diff --git a/.agents/skills/homarr-custom-widget/references/security.md b/.agents/skills/homarr-custom-widget/references/security.md index 4b05f500b..1abec790b 100644 --- a/.agents/skills/homarr-custom-widget/references/security.md +++ b/.agents/skills/homarr-custom-widget/references/security.md @@ -4,4 +4,4 @@ All requests use Homarr's protected server executor. Source origin, network scop The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags. -Credentials are stored separately and never exported or returned to an agent. +Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options. diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index e35c14244..d13fd6674 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -93,6 +93,6 @@ Custom JSX v2 provides four HTTP discovery endpoints: - `GET /api/custom-widgets/components` returns the release-matched static component catalog. - `GET /api/custom-widgets/skill` returns the concise live `SKILL.md` plus skills.sh, source, and install metadata. Retrieve the schema, component catalog, and examples through their dedicated resources only when needed. -Call `customWidget_secretRequestUser` to create a temporary credential entry URL. Do not construct or save this URL. +Call `customWidget_configurationRequestUser` to create a temporary API source setup URL where the user can confirm their server URL and enter credentials. Do not construct or save this URL. Authenticated tRPC and MCP procedures support definition management, validation, previews, template patches, and credential writes. An exported definition does not contain credentials. diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index 23e3cf8b0..1f3129b4e 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -60,14 +60,23 @@ These interfaces do not return an archive URL or archive hash. 5. Create a preview session. 6. Test the queries and actions. 7. Open the preview URL with browser tools. -8. Set the credential if the user supplied it. -9. Otherwise, ask the user for the credential with the ten-minute Homarr credential URL. -10. Inspect the redacted journal. -11. Patch the definition until it is valid. -12. Create the widget. +8. Inspect the redacted journal and revise the local candidate until it is valid. +9. Create the widget, including any source URLs and credentials the user already supplied. +10. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL. +11. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again. +12. Update the saved definition if the final verification finds a problem. 13. Repeat the procedure for the next widget. -Each credential URL applies to one definition or preview, one source, and the required fields. The agent receives only the request status. +Each source-configuration URL applies to one definition or preview and one source. The user confirms the server URL, network scope, and required credential fields directly in Homarr. The agent receives only request status and never receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist. + +Agents can install existing widgets without regenerating them: + +1. Search with `customWidget_workshopSearch`. +2. Inspect and validate a result with `customWidget_workshopGet`. +3. Install it with `customWidget_workshopInstall`. +4. Configure deployment-specific URLs and supplied credentials with `customWidget_sourceConfigure`. +5. If the agent does not have those values, call `customWidget_configurationRequestUser` and wait for completion. +6. Test the installed definition, then use `board_addItem` with kind `customApi` and the returned definition ID. Homarr encrypts the plaintext immediately. Homarr does not return the plaintext. diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index aa7334880..31b4fe9a1 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -61,9 +61,17 @@ The workbench keeps one continuous layout with visual source, request, and optio 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. +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. + ## Import and Workshop -Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. It also lets the user supply each required credential before import. Workshop publishes technically valid widgets immediately; users can vote or report them, and administrators can delete submissions. +Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page. + +For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work. + +Workshop publishes technically valid widgets immediately; users can vote or report them, and administrators can delete submissions. Publishers should replace personal private-network hosts with a clear service placeholder before publishing. + +Connected agents can search, inspect, and install Workshop widgets with `customWidget_workshopSearch`, `customWidget_workshopGet`, and `customWidget_workshopInstall`. After installation they can set a source URL and credentials with `customWidget_sourceConfigure`, or create a secure user-facing setup URL with `customWidget_configurationRequestUser`. Secret values are never returned through MCP. ## Bundled examples diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 4f3b818dc..35a17b52b 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -177,13 +177,14 @@ Then, read the live schema and component resources. These resources describe the 5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`. 6. Open the returned preview URL. 7. Examine `customWidget_previewJournal`. -8. Change the definition with `customWidget_templatePatch` or `customWidget_update`. -9. Set a supplied credential with `customWidget_secretSet`. -10. Otherwise, call `customWidget_secretRequestUser` to create a ten-minute Homarr entry URL. -11. Call `customWidget_create`. -12. Repeat this procedure for the next Custom Widget. +8. Revise the local candidate and create another preview session until it is ready. +9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided. +10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion. +11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again. +12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections. +13. Repeat this procedure for the next Custom Widget. -The agent receives the credential request status. The agent does not receive the plaintext credential. +The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist. Discovery resources: @@ -210,7 +211,11 @@ The Custom Widget tools are: - `customWidget_previewAction` - `customWidget_previewJournal` - `customWidget_secretSet` -- `customWidget_secretRequestUser` +- `customWidget_sourceConfigure` +- `customWidget_configurationRequestUser` +- `customWidget_workshopSearch` +- `customWidget_workshopGet` +- `customWidget_workshopInstall` - `customWidget_getAuthoringPrompt` - `customWidget_getSkill` diff --git a/apps/nextjs/src/app/[locale]/custom-widget-secret/[token]/secret-entry.tsx b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx similarity index 74% rename from apps/nextjs/src/app/[locale]/custom-widget-secret/[token]/secret-entry.tsx rename to apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx index baf8836f7..c943d6f9c 100644 --- a/apps/nextjs/src/app/[locale]/custom-widget-secret/[token]/secret-entry.tsx +++ b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Alert, Button, Card, Center, PasswordInput, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core"; +import { Alert, Button, Card, Center, PasswordInput, Select, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core"; import { IconCheck, IconKey, IconLock } from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; @@ -12,22 +12,30 @@ interface RequestDetails { kinds: Array<"apiKey" | "username" | "password">; expiresAt: number; status: "pending" | "completed"; + source: { + baseUrl: string; + networkScope: "public" | "private" | "loopback"; + }; } -export function CustomWidgetSecretEntry({ token }: { token: string }) { +export function CustomWidgetConfigurationEntry({ token }: { token: string }) { const t = useScopedI18n("customWidget.secretEntry"); const [details, setDetails] = useState(null); const [values, setValues] = useState>({}); + const [baseUrl, setBaseUrl] = useState(""); + const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public"); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { - void fetch(`/api/custom-widgets/secret-request/${encodeURIComponent(token)}`) + void fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`) .then(async (response) => { const body = (await response.json()) as RequestDetails | { error: string }; if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable")); setDetails(body); + setBaseUrl(body.source.baseUrl); + setNetworkScope(body.source.networkScope); }) .catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable"))) .finally(() => setLoading(false)); @@ -37,10 +45,10 @@ export function CustomWidgetSecretEntry({ token }: { token: string }) { setSaving(true); setError(null); try { - const response = await fetch(`/api/custom-widgets/secret-request/${encodeURIComponent(token)}`, { + const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ secrets: values }), + body: JSON.stringify({ baseUrl, networkScope, secrets: values }), }); const body = (await response.json()) as { status?: string; error?: string }; if (!response.ok) throw new Error(body.error ?? t("saveError")); @@ -81,6 +89,14 @@ export function CustomWidgetSecretEntry({ token }: { token: string }) { {t("source", { name: details.sourceName })} + setBaseUrl(event.currentTarget.value)} required /> + setValue(requirement.sourceId, requirement.kind, event.currentTarget.value)} - autoComplete="off" - /> - ); - })} - - )} + setValues((current) => ({ ...current, [sourceId]: value }))} + messages={{ + title: t("importReview.sourceSetup.title"), + description: t("importReview.sourceSetup.description"), + suggestedUrl: t("importReview.sourceSetup.suggestedUrl"), + baseUrl: t("workbench.sources.baseUrl"), + networkScope: t("workbench.sources.networkScope"), + authentication: t("workbench.sources.authentication"), + confirmUrl: t("importReview.sourceSetup.confirmUrl"), + ready: t("importReview.sourceSetup.ready"), + needsUrl: t("importReview.sourceSetup.needsUrl"), + credentialsMissing: t("importReview.sourceSetup.credentialsMissing"), + credentialsOptional: t("importReview.sourceSetup.credentialsOptional"), + configured: t("workbench.sources.configured"), + secret: (kind) => t(`importReview.credentials.field.${kind}`), + urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`), + }} + /> ); } diff --git a/apps/nextjs/src/components/workshop/workshop-client.ts b/apps/nextjs/src/components/workshop/workshop-client.ts index f8e46adce..4d68e542f 100644 --- a/apps/nextjs/src/components/workshop/workshop-client.ts +++ b/apps/nextjs/src/components/workshop/workshop-client.ts @@ -1,13 +1,14 @@ -import { WorkshopClient, WORKSHOP_API_URL } from "@homarr/workshop"; +import { WorkshopBackend } from "@homarr/workshop/backend"; +import { WORKSHOP_API_URL } from "@homarr/workshop/schema"; -let browserClient: WorkshopClient | undefined; +let browserClient: WorkshopBackend | undefined; export function createWorkshopClient() { - if (typeof document === "undefined") return new WorkshopClient(WORKSHOP_API_URL); + if (typeof document === "undefined") return new WorkshopBackend(WORKSHOP_API_URL); if (browserClient) return browserClient; const runtimeUrl = document.querySelector('meta[name="homarr-workshop-api-url"]')?.content; - browserClient = new WorkshopClient(runtimeUrl || WORKSHOP_API_URL); + browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL); return browserClient; } diff --git a/apps/nextjs/src/components/workshop/workshop-install-button.tsx b/apps/nextjs/src/components/workshop/workshop-install-button.tsx index b60e1fab5..d9f1813e6 100644 --- a/apps/nextjs/src/components/workshop/workshop-install-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-install-button.tsx @@ -1,8 +1,8 @@ "use client"; +import type { ReactNode } from "react"; import { useState } from "react"; -import { Button, Modal } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; +import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core"; import { IconBuildingStore } from "@tabler/icons-react"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; @@ -11,33 +11,52 @@ import { useScopedI18n } from "@homarr/translation/client"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { WorkshopBrowser } from "./workshop-browser"; -export function WorkshopInstallButton() { +interface WorkshopInstallButtonProps { + children?: ReactNode; + fullWidth?: boolean; +} + +export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) { const t = useScopedI18n("workshop"); - const [opened, controls] = useDisclosure(false); - const [reviewOpened, reviewControls] = useDisclosure(false); + const stack = useModalsStack(["workshop", "review"]); const [pendingWidget, setPendingWidget] = useState(null); + const zIndex = getDefaultZIndex("modal") + 10; + + const closeReview = () => { + stack.close("review"); + setPendingWidget(null); + }; + return ( <> - - - { - setPendingWidget(widget); - reviewControls.open(); + + + { + setPendingWidget(widget); + stack.open("review"); + }} + /> + + { + stack.closeAll(); + setPendingWidget(null); }} /> - - { - reviewControls.close(); - setPendingWidget(null); - }} - onImported={controls.close} - /> + ); } diff --git a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx index ca2ca2825..c5b9a3662 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx +++ b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx @@ -1,11 +1,12 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Alert, Button, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core"; +import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { showErrorNotification } from "@homarr/notifications"; -import type { WorkshopUser } from "@homarr/workshop"; +import { useWorkshopCreateMutation } from "@homarr/workshop/backend"; +import type { WorkshopUser } from "@homarr/workshop/schema"; import { useScopedI18n } from "@homarr/translation/client"; import { createWorkshopClient } from "./workshop-client"; @@ -26,9 +27,14 @@ export function WorkshopPublishModal({ const [title, setTitle] = useState(widget.name); const [description, setDescription] = useState(""); const [screenshots, setScreenshots] = useState([]); - const [pending, setPending] = useState(false); + const createSubmission = useWorkshopCreateMutation(client); const [error, setError] = useState(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", + ); useEffect(() => { const unsubscribe = client.subscribeToAuth(setUser); @@ -42,22 +48,20 @@ export function WorkshopPublishModal({ setScreenshots([]); setError(null); setPublished(false); + setSourceUrlsReviewed(false); }, [opened, widget.name]); const publish = async () => { - setPending(true); setError(null); try { const definition = await utils.customWidget.export.fetch({ id: widget.id }); - await client.create( - { type: "customWidget", title, description, content: JSON.stringify(definition) }, + await createSubmission.mutateAsync({ + input: { type: "customWidget", title, description, content: JSON.stringify(definition) }, screenshots, - ); + }); setPublished(true); } catch (cause) { setError(cause instanceof Error ? cause.message : t("publish.error")); - } finally { - setPending(false); } }; @@ -109,8 +113,25 @@ export function WorkshopPublishModal({ value={screenshots} onChange={setScreenshots} /> + {privateSources.length > 0 && ( + + {t("publish.privateSourceWarning", { + sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "), + })} + setSourceUrlsReviewed(event.currentTarget.checked)} + label={t("publish.privateSourceConfirmation")} + /> + + )} {error && {error}} - diff --git a/packages/api/package.json b/packages/api/package.json index 6d8ae586e..ddfb78b57 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -15,7 +15,7 @@ "./shared": "./src/shared.ts", "./open-api": "./src/open-api.ts", "./mcp": "./src/mcp.ts", - "./custom-widget-secrets": "./src/router/custom-widget/secret-requests.ts" + "./custom-widget-configuration": "./src/custom-widget-configuration.ts" }, "scripts": { "clean": "rm -rf .turbo node_modules", @@ -44,6 +44,7 @@ "@homarr/server-settings": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", + "@homarr/workshop": "workspace:^0.1.0", "@kubernetes/client-node": "catalog:", "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", diff --git a/packages/api/src/custom-widget-configuration.ts b/packages/api/src/custom-widget-configuration.ts new file mode 100644 index 000000000..ab155cd39 --- /dev/null +++ b/packages/api/src/custom-widget-configuration.ts @@ -0,0 +1,6 @@ +export * from "./router/custom-widget/configuration-requests"; +export { configurePreviewSessionSource } from "./router/custom-widget/preview-sessions"; +export { + parseStoredCustomWidgetDefinition, + serializeCustomWidgetDefinition, +} from "./router/custom-widget/stored-definition"; diff --git a/packages/api/src/router/custom-widget/configuration-requests.ts b/packages/api/src/router/custom-widget/configuration-requests.ts new file mode 100644 index 000000000..00aee7574 --- /dev/null +++ b/packages/api/src/router/custom-widget/configuration-requests.ts @@ -0,0 +1,104 @@ +import { createId } from "@homarr/common"; +import { createRedisClient } from "@homarr/core/infrastructure/redis"; +import type { RedisClient } from "@homarr/core/infrastructure/redis"; +import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core"; +import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; + +const CONFIGURATION_REQUEST_TTL_MS = 10 * 60_000; +const CONFIGURATION_REQUEST_PREFIX = "custom-widget:configuration-request:"; +const CONFIGURATION_REQUEST_LOCK_PREFIX = "custom-widget:configuration-request-lock:"; + +export interface CustomWidgetConfigurationRequest { + id: string; + userId: string; + target: { type: "definition"; id: string } | { type: "preview"; id: string }; + widgetName: string; + sourceId: string; + sourceName: string; + source: CustomWidgetSource; + kinds: CustomWidgetSecretKind[]; + expiresAt: number; + status: "pending" | "completed"; +} + +const localRequests = new Map(); +const localLocks = new Set(); +let redis: RedisClient | undefined; + +function getRedis() { + if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return undefined; + redis ??= createRedisClient(); + return redis; +} + +async function save(request: CustomWidgetConfigurationRequest) { + const ttl = Math.max(1, request.expiresAt - Date.now()); + const client = getRedis(); + if (client) await client.set(`${CONFIGURATION_REQUEST_PREFIX}${request.id}`, JSON.stringify(request), "PX", ttl); + else localRequests.set(request.id, request); +} + +export async function createCustomWidgetConfigurationRequest( + input: Omit, +) { + const request: CustomWidgetConfigurationRequest = { + ...input, + id: createId(), + expiresAt: Date.now() + CONFIGURATION_REQUEST_TTL_MS, + status: "pending", + }; + await save(request); + return request; +} + +export async function getCustomWidgetConfigurationRequest(id: string) { + const client = getRedis(); + const candidate = client ? await client.get(`${CONFIGURATION_REQUEST_PREFIX}${id}`) : localRequests.get(id); + if (!candidate) return null; + try { + const request = (typeof candidate === "string" ? JSON.parse(candidate) : candidate) as CustomWidgetConfigurationRequest; + if (request.expiresAt <= Date.now()) { + if (client) await client.del(`${CONFIGURATION_REQUEST_PREFIX}${id}`); + else localRequests.delete(id); + return null; + } + return request; + } catch { + return null; + } +} + +export async function getCustomWidgetConfigurationRequestForUser(id: string, userId: string) { + const request = await getCustomWidgetConfigurationRequest(id); + return request?.userId === userId ? request : null; +} + +export async function completeCustomWidgetConfigurationRequest(id: string) { + const request = await getCustomWidgetConfigurationRequest(id); + if (!request || request.status !== "pending") return null; + const completed = { ...request, status: "completed" as const }; + await save(completed); + await releaseCustomWidgetConfigurationRequest(id); + return completed; +} + +export async function claimCustomWidgetConfigurationRequest(id: string) { + const request = await getCustomWidgetConfigurationRequest(id); + if (!request || request.status !== "pending") return null; + const client = getRedis(); + if (client) { + const claimed = await client.set(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`, "1", "PX", 60_000, "NX"); + return claimed === "OK" ? request : null; + } + if (localLocks.has(id)) return null; + localLocks.add(id); + return request; +} + +export async function releaseCustomWidgetConfigurationRequest(id: string) { + const client = getRedis(); + if (client) await client.del(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`); + else localLocks.delete(id); +} + +export { setPreviewSessionSecrets } from "./preview-sessions"; diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts index f1dc1c1e1..ce963fc87 100644 --- a/packages/api/src/router/custom-widget/custom-widget-router.ts +++ b/packages/api/src/router/custom-widget/custom-widget-router.ts @@ -28,6 +28,7 @@ import { templateProcedures } from "./template-procedures"; import { transferProcedures } from "./transfer-procedures"; import { assertSecretSources, requiredSecretKinds } from "./secret-policy"; import { secretProcedures } from "./secret-procedures"; +import { workshopProcedures } from "./workshop-procedures"; const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage"); const logger = createLogger({ module: "custom-widget" }); @@ -167,6 +168,7 @@ export const customWidgetRouter = createTRPCRouter({ }), ...secretProcedures, + ...workshopProcedures, toggleEnabled: manageProcedure .input(z.object({ id: z.string(), enabled: z.boolean() })) diff --git a/packages/api/src/router/custom-widget/preview-sessions.ts b/packages/api/src/router/custom-widget/preview-sessions.ts index 2cf0a1cd8..7e2a92ea6 100644 --- a/packages/api/src/router/custom-widget/preview-sessions.ts +++ b/packages/api/src/router/custom-widget/preview-sessions.ts @@ -80,3 +80,10 @@ export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, so getService().getSecrets(session, sourceId); export const setPreviewSessionSecrets = (id: string, userId: string, secrets: CreatePreviewSessionInput["secrets"]) => call(() => getService().setSecrets(id, userId, secrets)); +export const configurePreviewSessionSource = ( + id: string, + userId: string, + sourceId: string, + source: CreatePreviewSessionInput["sources"][string], + secrets: CreatePreviewSessionInput["secrets"], +) => call(() => getService().configureSource(id, userId, sourceId, source, secrets)); diff --git a/packages/api/src/router/custom-widget/secret-procedures.ts b/packages/api/src/router/custom-widget/secret-procedures.ts index bfa9f02cc..ca7a8e880 100644 --- a/packages/api/src/router/custom-widget/secret-procedures.ts +++ b/packages/api/src/router/custom-widget/secret-procedures.ts @@ -2,15 +2,23 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { encryptSecret } from "@homarr/common/server"; -import { and, eq } from "@homarr/db"; +import { and, eq, handleTransactionsAsync } from "@homarr/db"; import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; -import { customWidgetSecretInputSchema } from "@homarr/custom-widgets/core"; +import { + customWidgetSecretInputSchema, + customWidgetSecretsInputSchema, + customWidgetSourceSchema, +} from "@homarr/custom-widgets/core"; +import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; import { permissionRequiredProcedure } from "../../trpc"; -import { createCustomWidgetSecretRequest, getCustomWidgetSecretRequestForUser } from "./secret-requests"; +import { + createCustomWidgetConfigurationRequest, + getCustomWidgetConfigurationRequestForUser, +} from "./configuration-requests"; import { assertSecretSources, requiredSecretKinds } from "./secret-policy"; import { getPreviewSession } from "./preview-sessions"; -import { parseStoredCustomWidgetDefinition } from "./stored-definition"; +import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition"; const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage"); const secretWriteProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-secret-write"); @@ -69,18 +77,108 @@ export const secretProcedures = { return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true }; }), - secretRequestUser: manageProcedure - .meta({ mcp: { enabled: true, description: "Create or check a short-lived user credential-entry request." } }) + sourceConfigure: manageProcedure + .meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } }) + .input( + z.object({ + definitionId: z.string(), + sourceId: z.string(), + baseUrl: z.string(), + networkScope: z.enum(["public", "private", "loopback"]).optional(), + secrets: customWidgetSecretsInputSchema.default([]), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) { + throw new TRPCError({ code: "FORBIDDEN", message: "Writing source credentials requires dedicated permission" }); + } + const stored = await ctx.db.query.customWidgetDefinitions.findFirst({ + where: eq(customWidgetDefinitions.id, input.definitionId), + }); + if (!stored) throw new TRPCError({ code: "NOT_FOUND" }); + const definition = parseStoredCustomWidgetDefinition(stored); + const current = definition.sources[input.sourceId]; + if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" }); + const source = customWidgetSourceSchema.parse({ + ...current, + baseUrl: input.baseUrl, + networkScope: input.networkScope ?? current.networkScope, + }); + assertSecretSources({ [input.sourceId]: source }, input.secrets); + const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } }; + const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() }; + const secretRows = input.secrets.map((secret) => ({ + definitionId: input.definitionId, + sourceId: input.sourceId, + kind: secret.kind, + encryptedValue: encryptSecret(secret.value), + updatedAt: new Date(), + })); + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + await transaction + .update(schema.customWidgetDefinitions) + .set(definitionChanges) + .where(eq(schema.customWidgetDefinitions.id, input.definitionId)); + for (const secret of secretRows) { + await transaction + .delete(schema.customWidgetSecrets) + .where( + and( + eq(schema.customWidgetSecrets.definitionId, input.definitionId), + eq(schema.customWidgetSecrets.sourceId, input.sourceId), + eq(schema.customWidgetSecrets.kind, secret.kind), + ), + ); + await transaction.insert(schema.customWidgetSecrets).values(secret); + } + }); + }, + handleSync(db) { + db.transaction((transaction) => { + transaction + .update(customWidgetDefinitions) + .set(definitionChanges) + .where(eq(customWidgetDefinitions.id, input.definitionId)) + .run(); + for (const secret of secretRows) { + transaction + .delete(customWidgetSecrets) + .where( + and( + eq(customWidgetSecrets.definitionId, input.definitionId), + eq(customWidgetSecrets.sourceId, input.sourceId), + eq(customWidgetSecrets.kind, secret.kind), + ), + ) + .run(); + transaction.insert(customWidgetSecrets).values(secret).run(); + } + }); + }, + }); + return { + definitionId: input.definitionId, + sourceId: input.sourceId, + baseUrl: source.baseUrl, + networkScope: source.networkScope, + configuredSecrets: input.secrets.map(({ kind }) => kind), + }; + }), + + configurationRequestUser: manageProcedure + .meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } }) .input(secretRequestInputSchema) .mutation(async ({ ctx, input }) => { if (input.requestId) { - const request = await getCustomWidgetSecretRequestForUser(input.requestId, ctx.session.user.id); + const request = await getCustomWidgetConfigurationRequestForUser(input.requestId, ctx.session.user.id); if (!request) throw new TRPCError({ code: "NOT_FOUND" }); return { requestId: request.id, status: request.status, expiresAt: request.expiresAt }; } let widgetName: string; - let source: { id: string; name: string; auth: string | { type: string } } | undefined; + let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined; let target: { type: "definition"; id: string } | { type: "preview"; id: string }; if (input.definitionId) { const stored = await ctx.db.query.customWidgetDefinitions.findFirst({ @@ -91,36 +189,44 @@ export const secretProcedures = { widgetName = definition.name; const candidate = definition.sources[input.sourceId ?? ""]; source = candidate - ? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth } + ? { + id: input.sourceId ?? "", + name: candidate.name ?? input.sourceId ?? "", + auth: candidate.auth, + value: candidate, + } : undefined; target = { type: "definition", id: input.definitionId }; } else { const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id); - widgetName = "Custom widget preview"; + widgetName = preview.name; const candidate = preview.sources[input.sourceId ?? ""]; source = candidate - ? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth } + ? { + id: input.sourceId ?? "", + name: candidate.name ?? input.sourceId ?? "", + auth: candidate.auth, + value: candidate, + } : undefined; target = { type: "preview", id: preview.id }; } if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" }); const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)]; - if (kinds.length === 0) { - throw new TRPCError({ code: "BAD_REQUEST", message: "This source does not require a secret" }); - } - const request = await createCustomWidgetSecretRequest({ + const request = await createCustomWidgetConfigurationRequest({ userId: ctx.session.user.id, target, widgetName, sourceId: source.id, sourceName: source.name, + source: source.value, kinds, }); return { requestId: request.id, status: request.status, expiresAt: request.expiresAt, - url: new URL(`/custom-widget-secret/${request.id}`, ctx.baseUrl ?? "http://localhost").toString(), + url: new URL(`/custom-widget-configuration/${request.id}`, ctx.baseUrl ?? "http://localhost").toString(), }; }), diff --git a/packages/api/src/router/custom-widget/secret-requests.ts b/packages/api/src/router/custom-widget/secret-requests.ts deleted file mode 100644 index 9936924d3..000000000 --- a/packages/api/src/router/custom-widget/secret-requests.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createId } from "@homarr/common"; -import { createRedisClient } from "@homarr/core/infrastructure/redis"; -import type { RedisClient } from "@homarr/core/infrastructure/redis"; -import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core"; - -const SECRET_REQUEST_TTL_MS = 10 * 60_000; -const SECRET_REQUEST_PREFIX = "custom-widget:secret-request:"; -const SECRET_REQUEST_LOCK_PREFIX = "custom-widget:secret-request-lock:"; - -export interface CustomWidgetSecretRequest { - id: string; - userId: string; - target: { type: "definition"; id: string } | { type: "preview"; id: string }; - widgetName: string; - sourceId: string; - sourceName: string; - kinds: CustomWidgetSecretKind[]; - expiresAt: number; - status: "pending" | "completed"; -} - -const localRequests = new Map(); -const localLocks = new Set(); -let redis: RedisClient | undefined; - -function getRedis() { - if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return undefined; - redis ??= createRedisClient(); - return redis; -} - -async function save(request: CustomWidgetSecretRequest) { - const ttl = Math.max(1, request.expiresAt - Date.now()); - const client = getRedis(); - if (client) await client.set(`${SECRET_REQUEST_PREFIX}${request.id}`, JSON.stringify(request), "PX", ttl); - else localRequests.set(request.id, request); -} - -export async function createCustomWidgetSecretRequest( - input: Omit, -) { - const request: CustomWidgetSecretRequest = { - ...input, - id: createId(), - expiresAt: Date.now() + SECRET_REQUEST_TTL_MS, - status: "pending", - }; - await save(request); - return request; -} - -export async function getCustomWidgetSecretRequest(id: string) { - const client = getRedis(); - const candidate = client ? await client.get(`${SECRET_REQUEST_PREFIX}${id}`) : localRequests.get(id); - if (!candidate) return null; - try { - const request = (typeof candidate === "string" ? JSON.parse(candidate) : candidate) as CustomWidgetSecretRequest; - if (request.expiresAt <= Date.now()) { - if (client) await client.del(`${SECRET_REQUEST_PREFIX}${id}`); - else localRequests.delete(id); - return null; - } - return request; - } catch { - return null; - } -} - -export async function getCustomWidgetSecretRequestForUser(id: string, userId: string) { - const request = await getCustomWidgetSecretRequest(id); - return request?.userId === userId ? request : null; -} - -export async function completeCustomWidgetSecretRequest(id: string) { - const request = await getCustomWidgetSecretRequest(id); - if (!request || request.status !== "pending") return null; - const completed = { ...request, status: "completed" as const }; - await save(completed); - await releaseCustomWidgetSecretRequest(id); - return completed; -} - -export async function claimCustomWidgetSecretRequest(id: string) { - const request = await getCustomWidgetSecretRequest(id); - if (!request || request.status !== "pending") return null; - const client = getRedis(); - if (client) { - const claimed = await client.set(`${SECRET_REQUEST_LOCK_PREFIX}${id}`, "1", "PX", 60_000, "NX"); - return claimed === "OK" ? request : null; - } - if (localLocks.has(id)) return null; - localLocks.add(id); - return request; -} - -export async function releaseCustomWidgetSecretRequest(id: string) { - const client = getRedis(); - if (client) await client.del(`${SECRET_REQUEST_LOCK_PREFIX}${id}`); - else localLocks.delete(id); -} - -export { setPreviewSessionSecrets } from "./preview-sessions"; diff --git a/packages/api/src/router/custom-widget/workshop-procedures.ts b/packages/api/src/router/custom-widget/workshop-procedures.ts new file mode 100644 index 000000000..c18b98f11 --- /dev/null +++ b/packages/api/src/router/custom-widget/workshop-procedures.ts @@ -0,0 +1,172 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod/v4"; + +import { + applyCustomWidgetSourceSetup, + CUSTOM_WIDGET_SCHEMA, + customWidgetDefinitionSchema, + customWidgetIdentifierSchema, + customWidgetSecretsInputSchema, + getCustomWidgetSecretRequirements, + getCustomWidgetSourceSetups, +} from "@homarr/custom-widgets/core"; +import { WorkshopBackend } from "@homarr/workshop/backend"; +import { validateWorkshopWidget, WORKSHOP_API_URL } from "@homarr/workshop/schema"; + +import { permissionRequiredProcedure } from "../../trpc"; +import { insertCustomWidgetDefinition } from "./definition-insert"; +import { assertSecretSources } from "./secret-policy"; + +const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage"); +const workshop = new WorkshopBackend(process.env.WORKSHOP_API_URL ?? WORKSHOP_API_URL); + +async function getWorkshopWidget(submissionId: string) { + try { + const submission = await workshop.get(submissionId); + if (submission.type !== "customWidget" || submission.widgetSchema !== CUSTOM_WIDGET_SCHEMA) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workshop submission is not a compatible Custom JSX widget", + }); + } + const validation = validateWorkshopWidget(submission.content); + if (!validation.success) { + throw new TRPCError({ code: "BAD_REQUEST", message: validation.error }); + } + return { submission, widget: validation.data }; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "BAD_GATEWAY", + message: error instanceof Error ? error.message : "Workshop is unavailable", + cause: error, + }); + } +} + +const workshopSearchInputSchema = z.object({ + query: z.string().trim().min(1).max(200), + sort: z.enum(["top", "newest", "recent", "discussed"]).default("top"), + limit: z.number().int().min(1).max(20).default(10), +}); + +const sourceOverridesSchema = z.record( + customWidgetIdentifierSchema, + z.strictObject({ + baseUrl: z.string(), + networkScope: z.enum(["public", "private", "loopback"]).optional(), + }), +); + +export const workshopProcedures = { + workshopSearch: manageProcedure + .meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } }) + .input(workshopSearchInputSchema) + .query(async ({ input }) => { + try { + const result = await workshop.list({ + page: 1, + perPage: input.limit, + search: input.query, + sort: input.sort, + type: "customWidget", + includeOutdated: true, + }); + return { + items: result.items + .filter((item) => item.widgetSchema === CUSTOM_WIDGET_SCHEMA) + .map((item) => ({ + id: item.id, + title: item.title, + description: item.description, + authorName: item.authorName, + score: item.score, + revision: item.revision, + outdated: item.outdated, + reportCount: item.reportCount, + })), + }; + } catch (error) { + throw new TRPCError({ + code: "BAD_GATEWAY", + message: error instanceof Error ? error.message : "Workshop is unavailable", + cause: error, + }); + } + }), + + workshopGet: manageProcedure + .meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } }) + .input(z.object({ submissionId: z.string().min(1) })) + .query(async ({ input }) => { + const { submission, widget } = await getWorkshopWidget(input.submissionId); + return { + submission: { + id: submission.id, + title: submission.title, + description: submission.description, + authorName: submission.authorName, + revision: submission.revision, + score: submission.score, + outdated: submission.outdated, + reportCount: submission.reportCount, + }, + widget, + sourceSetup: getCustomWidgetSourceSetups(widget.sources), + hasActions: Object.values(widget.requests).some((request) => request.kind === "action"), + methods: [...new Set(Object.values(widget.requests).map((request) => request.method))], + permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))], + }; + }), + + workshopInstall: manageProcedure + .meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } }) + .input( + z.object({ + submissionId: z.string().min(1), + name: z.string().trim().min(1).max(128).optional(), + sources: sourceOverridesSchema.default({}), + secrets: customWidgetSecretsInputSchema.default([]), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Writing Workshop widget credentials requires dedicated permission", + }); + } + const { widget } = await getWorkshopWidget(input.submissionId); + const unknownSource = Object.keys(input.sources).find((sourceId) => !widget.sources[sourceId]); + if (unknownSource) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown Workshop widget source '${unknownSource}'` }); + } + const configured = customWidgetDefinitionSchema.parse({ + ...widget, + name: input.name ?? widget.name, + sources: applyCustomWidgetSourceSetup( + widget.sources, + Object.fromEntries( + Object.entries(input.sources).map(([sourceId, source]) => [ + sourceId, + { + baseUrl: source.baseUrl, + networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public", + }, + ]), + ), + ), + }); + assertSecretSources(configured.sources, input.secrets); + const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets); + const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`)); + return { + status: "installed" as const, + definitionId: id, + sourceSetup: getCustomWidgetSourceSetups(configured.sources, input.secrets), + missingCredentials: getCustomWidgetSecretRequirements(configured.sources) + .filter((requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`)) + .map(({ sourceId, kind }) => ({ sourceId, kind })), + }; + }), +}; diff --git a/packages/api/src/router/test/custom-widget/configuration-requests.spec.ts b/packages/api/src/router/test/custom-widget/configuration-requests.spec.ts new file mode 100644 index 000000000..e95efe440 --- /dev/null +++ b/packages/api/src/router/test/custom-widget/configuration-requests.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { + claimCustomWidgetConfigurationRequest, + completeCustomWidgetConfigurationRequest, + createCustomWidgetConfigurationRequest, + getCustomWidgetConfigurationRequestForUser, + releaseCustomWidgetConfigurationRequest, +} from "../../custom-widget/configuration-requests"; + +describe("custom widget source configuration requests", () => { + it("is user-scoped, one-time, and reports completion without returning configuration", async () => { + const request = await createCustomWidgetConfigurationRequest({ + userId: "user-1", + target: { type: "preview", id: "preview-1" }, + widgetName: "Printer", + sourceId: "default", + sourceName: "Printer API", + source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" }, + kinds: ["apiKey"], + }); + + expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull(); + expect(await claimCustomWidgetConfigurationRequest(request.id)).toMatchObject({ status: "pending" }); + expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull(); + await releaseCustomWidgetConfigurationRequest(request.id); + expect(await claimCustomWidgetConfigurationRequest(request.id)).not.toBeNull(); + await completeCustomWidgetConfigurationRequest(request.id); + expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({ status: "completed" }); + expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull(); + }); +}); diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts index 45d02a90b..6119726fe 100644 --- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts +++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts @@ -7,6 +7,7 @@ import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core"; import { describe, expect, test } from "vitest"; import { customWidgetRouter } from "../../custom-widget/custom-widget-router"; +import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests"; const userId = createId(); const session = { @@ -98,4 +99,60 @@ describe("custom widget definition persistence", () => { expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt); expect(storedSecret).toEqual(originalSecret); }); + + test("configures a source URL and credential together", async () => { + const db = await prepareDatabase(); + const caller = createCaller(db); + const created = await caller.create({ ...jellyfin, secrets: [] }); + + await caller.sourceConfigure({ + definitionId: created.id, + sourceId: "default", + baseUrl: "http://jellyfin.local:8096", + networkScope: "private", + secrets: [secret], + }); + + const definition = await caller.get({ id: created.id }); + expect(definition.sources.default?.baseUrl).toBe("http://jellyfin.local:8096"); + expect(definition.secrets).toMatchObject([{ sourceId: "default", kind: "apiKey", hasValue: true }]); + }); + + test("rolls back a source URL when its credential cannot be stored", async () => { + const db = await prepareDatabase(); + const caller = createCaller(db); + const created = await caller.create({ ...jellyfin, secrets: [] }); + const before = await caller.get({ id: created.id }); + rejectSecretInserts(db); + + await expect( + caller.sourceConfigure({ + definitionId: created.id, + sourceId: "default", + baseUrl: "http://jellyfin.local:8096", + networkScope: "private", + secrets: [secret], + }), + ).rejects.toThrow("forced secret insert failure"); + + expect((await caller.get({ id: created.id })).sources.default?.baseUrl).toBe(before.sources.default?.baseUrl); + }); + + test("uses the preview widget name in a user configuration request", async () => { + const db = await prepareDatabase(); + const caller = createCaller(db); + const preview = await caller.previewCreate({ + definition: { ...jellyfin, name: "Living room Jellyfin" }, + secrets: [], + }); + + const request = await caller.configurationRequestUser({ + previewSessionId: preview.previewSession.id, + sourceId: "default", + }); + + expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({ + widgetName: "Living room Jellyfin", + }); + }); }); diff --git a/packages/api/src/router/test/custom-widget/secret-requests.spec.ts b/packages/api/src/router/test/custom-widget/secret-requests.spec.ts deleted file mode 100644 index f3eda013d..000000000 --- a/packages/api/src/router/test/custom-widget/secret-requests.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - claimCustomWidgetSecretRequest, - completeCustomWidgetSecretRequest, - createCustomWidgetSecretRequest, - getCustomWidgetSecretRequestForUser, - releaseCustomWidgetSecretRequest, -} from "../../custom-widget/secret-requests"; - -describe("custom widget credential requests", () => { - it("is user-scoped, one-time, and reports completion without a secret", async () => { - const request = await createCustomWidgetSecretRequest({ - userId: "user-1", - target: { type: "preview", id: "preview-1" }, - widgetName: "Printer", - sourceId: "default", - sourceName: "Printer API", - kinds: ["apiKey"], - }); - - expect(await getCustomWidgetSecretRequestForUser(request.id, "user-2")).toBeNull(); - expect(await claimCustomWidgetSecretRequest(request.id)).toMatchObject({ status: "pending" }); - expect(await claimCustomWidgetSecretRequest(request.id)).toBeNull(); - await releaseCustomWidgetSecretRequest(request.id); - expect(await claimCustomWidgetSecretRequest(request.id)).not.toBeNull(); - await completeCustomWidgetSecretRequest(request.id); - expect(await getCustomWidgetSecretRequestForUser(request.id, "user-1")).toMatchObject({ status: "completed" }); - expect(await claimCustomWidgetSecretRequest(request.id)).toBeNull(); - }); -}); diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index f06158dc6..05ed7b43d 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -49,7 +49,11 @@ test("MCP tools should contain expected procedures", () => { expect(toolNames).toContain("customWidget_previewAction"); expect(toolNames).toContain("customWidget_previewJournal"); expect(toolNames).toContain("customWidget_secretSet"); - expect(toolNames).toContain("customWidget_secretRequestUser"); + 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"); @@ -98,7 +102,11 @@ test("custom widget public procedures remain available", () => { "setPreviewLiveActions", "previewJournal", "secretSet", - "secretRequestUser", + "sourceConfigure", + "configurationRequestUser", + "workshopSearch", + "workshopGet", + "workshopInstall", "secretClear", "getAuthoringPrompt", "getSkill", diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index ffea5a9ea..575f19ae7 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -91,13 +91,13 @@ const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboar Manifest contract: ${leanShape} -Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Never put credentials in the manifest. +Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest. Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE requires full permission and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options. Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option. -JSX reads data.requestId, status.requestId, options.name, and temporary inputs.name. A status has loading, ok, status, statusText, and error. Use bind="search" on supported controls and inputs.search in params. SubFetch invokes a manual query. With trigger="manual", it renders its own load button; never author onClick or a fetch callback. Its child callback is (result, meta), where meta has ok, status, statusText, loading=false, and no error because SubFetch renders loading/error states itself. Use expression callbacks for map, filter, sort, and SubFetch. Do not use imports, hooks, refs, raw HTML, event callbacks, fetch, eval, bigint, npm packages, authored const blocks, IIFEs, or recursion. Regex is only for bounded string matching/replacement. Do not embed secrets. +JSX reads data.requestId, status.requestId, options.name, and temporary inputs.name. A status has loading, ok, status, statusText, and error. Use bind="search" on supported controls and inputs.search in params. SubFetch invokes a manual query. With trigger="manual", it renders its own load button; pass a card or image as triggerContent with triggerAriaLabel to make that content launch the request. Never author onClick or a fetch callback. Its child callback is (result, meta), where meta has ok, status, statusText, loading=false, and no error because SubFetch renders loading/error states itself. Use expression callbacks for map, filter, sort, and SubFetch. Do not use imports, hooks, refs, raw HTML, event callbacks, fetch, eval, bigint, npm packages, authored const blocks, IIFEs, or recursion. Regex is only for bounded string matching/replacement. Do not embed secrets. Hard syntax rule: never write \`=> {\` anywhere. Every callback must be one concise expression, for example \`items.map(item => ...)\`. Inline derived values directly, even when that repeats a short expression. Never use an IIFE to create local variables or branch; use JSX ternaries instead. Callback parameter names must not shadow the reserved roots \`data\`, \`status\`, \`options\`, or \`inputs\`. @@ -123,9 +123,11 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT; export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time. -Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Request credentials through Homarr when needed and never repeat plaintext. The live resources are authoritative for this Homarr release. +Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release. -Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.`; +Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware. + +Use Workshop search/get/install when the user asks for an existing community widget. After installation, configure self-hosted source URLs and credentials with the dedicated source configuration tool or a secure user configuration request.`; export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) { const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT]; diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index 7ce78c611..36667cfea 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -7,7 +7,7 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL = "https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget"; export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND = "npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget"; -export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0"; +export const CUSTOM_WIDGET_SKILL_VERSION = "2.1.0"; export const CUSTOM_WIDGET_SKILL_MD = `--- name: homarr-custom-widget @@ -21,17 +21,21 @@ Author one widget at a time. When connected to Homarr, retrieve \`homarr://custo 1. Read the API documentation. 2. Create one credential-free widget with keyed \`sources\`, keyed \`requests\`, optional keyed \`options\`, and safe JSX \`template\`. 3. Validate, preview, test queries and simulated actions, visually inspect, and iterate. -4. Request credentials through Homarr and never repeat plaintext. +4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext. 5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`. -\`SubFetch\` with \`trigger="manual"\` renders its own load button. Its callback is \`(result, meta)\`; never author \`onClick\` or a fetch callback. +\`SubFetch\` with \`trigger="manual"\` renders its own load button. Pass a card or image through \`triggerContent\` with \`triggerAriaLabel\` when that content should launch the request. Its callback is \`(result, meta)\`; never author \`onClick\` or a fetch callback. Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards. Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session. +To install an existing widget, use \`customWidget_workshopSearch\`, inspect it with \`customWidget_workshopGet\`, then use \`customWidget_workshopInstall\`. Configure each self-hosted source afterward with \`customWidget_sourceConfigure\`, or call \`customWidget_configurationRequestUser\` so the user can enter the server URL and credentials directly in Homarr. + +Preview-scoped configuration expires with the preview. To persist user-entered values for a new widget, create the widget first, request configuration for its definition ID, then create a final preview with that definition ID so Homarr loads the stored credentials. + When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead. `; diff --git a/packages/custom-widgets/src/core/index.ts b/packages/custom-widgets/src/core/index.ts index 511e0ab3f..05f371794 100644 --- a/packages/custom-widgets/src/core/index.ts +++ b/packages/custom-widgets/src/core/index.ts @@ -5,4 +5,5 @@ export * from "./options"; export * from "./schema"; export * from "./schema-references"; export * from "./secret-requirements"; +export * from "./source-setup"; export * from "./tabler-icons"; diff --git a/packages/custom-widgets/src/core/source-setup.ts b/packages/custom-widgets/src/core/source-setup.ts new file mode 100644 index 000000000..a468ca7ba --- /dev/null +++ b/packages/custom-widgets/src/core/source-setup.ts @@ -0,0 +1,96 @@ +import type { CustomWidgetSource } from "./request-schema"; +import { getCustomWidgetSourceUrlIssue } from "./request-schema"; +import type { CustomWidgetSecretKind } from "./schema-types"; +import { getCustomWidgetSecretRequirements } from "./secret-requirements"; + +export interface CustomWidgetConfiguredSecret { + sourceId: string; + kind: CustomWidgetSecretKind; + hasValue?: boolean; + value?: string; +} + +export interface CustomWidgetSourceSetup { + sourceId: string; + sourceName: string; + suggestedBaseUrl: string; + baseUrl: string; + networkScope: CustomWidgetSource["networkScope"]; + authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery"; + credentialFields: Array<{ + kind: CustomWidgetSecretKind; + destination?: string; + configured: boolean; + }>; + requiresUrlConfirmation: boolean; +} + +const placeholderHosts = new Set(["example.com", "example.org", "example.net"]); + +export function isCustomWidgetSourceUrlPlaceholder(baseUrl: string) { + if (!URL.canParse(baseUrl)) return false; + const hostname = new URL(baseUrl).hostname.toLowerCase(); + return ( + placeholderHosts.has(hostname) || + hostname.endsWith(".example.com") || + hostname.endsWith(".example.org") || + hostname.endsWith(".example.net") || + /^(?:your-|replace-|change-me)/u.test(hostname) + ); +} + +export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) { + return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl); +} + +export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource, right: CustomWidgetSource) { + if (typeof left.auth === "string" || typeof right.auth === "string") return left.auth === right.auth; + return left.auth.type === right.auth.type && left.auth.name === right.auth.name; +} + +export function getCustomWidgetSourceSetups( + sources: Record, + configuredSecrets: readonly CustomWidgetConfiguredSecret[] = [], +): CustomWidgetSourceSetup[] { + const requirements = getCustomWidgetSecretRequirements(sources); + return Object.entries(sources).map(([sourceId, source]) => { + const authType = typeof source.auth === "string" ? source.auth : source.auth.type; + return { + sourceId, + sourceName: source.name ?? sourceId, + suggestedBaseUrl: source.baseUrl, + baseUrl: source.baseUrl, + networkScope: source.networkScope, + authType, + credentialFields: requirements + .filter((requirement) => requirement.sourceId === sourceId) + .map((requirement) => ({ + kind: requirement.kind, + destination: requirement.destination, + configured: configuredSecrets.some( + (secret) => + secret.sourceId === sourceId && + secret.kind === requirement.kind && + (secret.hasValue === true || Boolean(secret.value?.trim())), + ), + })), + requiresUrlConfirmation: customWidgetSourceRequiresUrlConfirmation(source), + }; + }); +} + +export function applyCustomWidgetSourceSetup( + sources: Record, + setup: Record>, +) { + return Object.fromEntries( + Object.entries(sources).map(([sourceId, source]) => { + const configured = setup[sourceId]; + return [sourceId, configured ? { ...source, ...configured } : source]; + }), + ); +} + +export function getCustomWidgetSourceSetupIssue(source: Pick) { + return getCustomWidgetSourceUrlIssue(source.baseUrl); +} diff --git a/packages/custom-widgets/src/server/preview-sessions.ts b/packages/custom-widgets/src/server/preview-sessions.ts index 28a7605a4..c7fee74fa 100644 --- a/packages/custom-widgets/src/server/preview-sessions.ts +++ b/packages/custom-widgets/src/server/preview-sessions.ts @@ -5,6 +5,7 @@ import { customWidgetRequestsSchema, customWidgetSecretKinds, customWidgetSourcesSchema, + hasSameCustomWidgetSourceAuthentication, } from "../core"; import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core"; import { CustomWidgetDomainError } from "./errors"; @@ -179,6 +180,40 @@ export class CustomWidgetPreviewSessionService { return { id, expiresAt: next.expiresAt }; } + public async configureSource( + id: string, + userId: string, + sourceId: string, + 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); + return { id, expiresAt: next.expiresAt }; + } + private async save(session: CustomWidgetPreviewSession): Promise { const ttlMs = Math.max(0, session.expiresAt - this.now()); if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" }); diff --git a/packages/custom-widgets/src/test/preview-sessions.spec.ts b/packages/custom-widgets/src/test/preview-sessions.spec.ts index f3e33c25d..445b3384e 100644 --- a/packages/custom-widgets/src/test/preview-sessions.spec.ts +++ b/packages/custom-widgets/src/test/preview-sessions.spec.ts @@ -66,4 +66,47 @@ describe("preview sessions", () => { }); expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 }); }); + + it("applies source deployment values without restoring stale source metadata", async () => { + let id = 0; + const service = new CustomWidgetPreviewSessionService({ + createId: () => `id-${++id}`, + encrypt: String, + decrypt: String, + }); + const created = await service.create({ + userId: "user", + sources: definition.sources, + requests: definition.requests, + name: definition.name, + template: definition.template, + optionDefinitions: definition.options, + options: { limit: 10 }, + secrets: [], + }); + + await service.configureSource( + created.id, + "user", + "default", + { baseUrl: "http://service.local", networkScope: "private", auth: "bearer" }, + [{ sourceId: "default", kind: "apiKey", value: "secret" }], + ); + + const configured = await service.get(created.id, "user"); + expect(configured.sources.default).toEqual({ + baseUrl: "http://service.local", + networkScope: "private", + auth: "bearer", + }); + await expect( + service.configureSource( + created.id, + "user", + "default", + { baseUrl: "http://service.local", networkScope: "private", auth: "none" }, + [], + ), + ).rejects.toThrow("authentication changed"); + }); }); diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts index ad9aee0f1..4028a8be6 100644 --- a/packages/custom-widgets/src/test/schema.spec.ts +++ b/packages/custom-widgets/src/test/schema.spec.ts @@ -6,6 +6,9 @@ import { customJsxRequestSchema, getCustomWidgetDefaultOptions, getCustomWidgetSecretRequirements, + getCustomWidgetSourceSetups, + hasSameCustomWidgetSourceAuthentication, + applyCustomWidgetSourceSetup, } from "../core"; describe("lean Custom Widget schema", () => { @@ -82,6 +85,65 @@ describe("lean Custom Widget schema", () => { ).toEqual(["default:apiKey", "admin:username", "admin:password"]); }); + it("requires installation confirmation for private and placeholder source URLs", () => { + const sources = { + default: { baseUrl: "https://pokeapi.co/api/v2", networkScope: "public" as const, auth: "none" as const }, + tautulli: { + name: "Tautulli", + baseUrl: "http://tautulli.local:8181", + networkScope: "private" as const, + auth: { type: "apiKeyQuery" as const, name: "apikey" }, + }, + placeholder: { + baseUrl: "https://api.example.com", + networkScope: "public" as const, + auth: "none" as const, + }, + }; + const setups = getCustomWidgetSourceSetups(sources); + expect(setups.map(({ sourceId, requiresUrlConfirmation }) => [sourceId, requiresUrlConfirmation])).toEqual([ + ["default", false], + ["tautulli", true], + ["placeholder", true], + ]); + expect(setups.find(({ sourceId }) => sourceId === "tautulli")?.credentialFields).toEqual([ + { kind: "apiKey", destination: "apikey", configured: false }, + ]); + }); + + it("applies source setup without changing source authentication", () => { + const sources = { + default: { + baseUrl: "http://tautulli.local:8181", + networkScope: "private" as const, + auth: { type: "apiKeyQuery" as const, name: "apikey" }, + }, + }; + expect( + applyCustomWidgetSourceSetup(sources, { + default: { baseUrl: "http://192.168.1.20:8181", networkScope: "private" }, + }).default, + ).toEqual({ + baseUrl: "http://192.168.1.20:8181", + networkScope: "private", + auth: { type: "apiKeyQuery", name: "apikey" }, + }); + }); + + it("detects stale source authentication snapshots", () => { + const bearer = { baseUrl: "https://example.com", networkScope: "public" as const, auth: "bearer" as const }; + expect(hasSameCustomWidgetSourceAuthentication(bearer, { ...bearer, baseUrl: "https://api.example.com" })).toBe( + true, + ); + expect(hasSameCustomWidgetSourceAuthentication(bearer, { ...bearer, auth: "none" })).toBe(false); + expect( + hasSameCustomWidgetSourceAuthentication( + { ...bearer, auth: { type: "apiKeyHeader", name: "X-API-Key" } }, + { ...bearer, auth: { type: "apiKeyHeader", name: "Authorization" } }, + ), + ).toBe(false); + }); + it("supports official Mantine compound names and separates bigint diagnostics", () => { expect( customWidgetDefinitionSchema.safeParse({ diff --git a/packages/custom-widgets/src/workbench/import-review.tsx b/packages/custom-widgets/src/workbench/import-review.tsx index fc1373347..365c4b0fb 100644 --- a/packages/custom-widgets/src/workbench/import-review.tsx +++ b/packages/custom-widgets/src/workbench/import-review.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { Alert, Badge, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core"; +import type { ModalProps } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; import type { ImportReview } from "../core/import"; @@ -24,8 +25,11 @@ export interface ImportReviewDialogProps { opened: boolean; review: ImportReview | null; pending: boolean; + confirmDisabled?: boolean; messages: ImportReviewMessages; children?: ReactNode; + stackId?: string; + zIndex?: ModalProps["zIndex"]; onClose(): void; onConfirm(): void; } @@ -45,13 +49,24 @@ export function ImportReviewDialog({ opened, review, pending, + confirmDisabled, messages, children, + stackId, + zIndex, onClose, onConfirm, }: ImportReviewDialogProps) { return ( - + {review && ( @@ -104,7 +119,7 @@ export function ImportReviewDialog({ - diff --git a/packages/custom-widgets/src/workbench/index.ts b/packages/custom-widgets/src/workbench/index.ts index 9844db25f..3454bdf3e 100644 --- a/packages/custom-widgets/src/workbench/index.ts +++ b/packages/custom-widgets/src/workbench/index.ts @@ -12,6 +12,7 @@ export * from "./preview-diagnostics-panel"; export * from "./preview-header"; export * from "./preview-utils"; export * from "./request-references"; +export * from "./source-setup"; export { CUSTOM_WIDGET_AUTH_SECRET_FIELDS, DEFAULT_CUSTOM_WIDGET_FORM_VALUES, diff --git a/packages/custom-widgets/src/workbench/source-setup.tsx b/packages/custom-widgets/src/workbench/source-setup.tsx new file mode 100644 index 000000000..6c2533a30 --- /dev/null +++ b/packages/custom-widgets/src/workbench/source-setup.tsx @@ -0,0 +1,189 @@ +import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core"; +import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react"; + +import type { + CustomWidgetSecretKind, + CustomWidgetSource, + CustomWidgetSourceSetup, + CustomWidgetSourceUrlIssue, +} from "../core"; +import { getCustomWidgetSourceSetupIssue } from "../core"; + +export interface CustomWidgetSourceSetupValue { + baseUrl: string; + networkScope: CustomWidgetSource["networkScope"]; + urlConfirmed: boolean; + secrets: Partial>; +} + +export interface CustomWidgetSourceSetupMessages { + title: string; + description: string; + suggestedUrl: string; + baseUrl: string; + networkScope: string; + authentication: string; + confirmUrl: string; + ready: string; + needsUrl: string; + credentialsMissing: string; + credentialsOptional: string; + configured: string; + secret(kind: CustomWidgetSecretKind): string; + urlError(issue: CustomWidgetSourceUrlIssue): string; +} + +export interface CustomWidgetSourceSetupPanelProps { + setups: CustomWidgetSourceSetup[]; + values: Record; + messages: CustomWidgetSourceSetupMessages; + onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void; +} + +export function createCustomWidgetSourceSetupValues( + setups: readonly CustomWidgetSourceSetup[], +): Record { + return Object.fromEntries( + setups.map((setup) => [ + setup.sourceId, + { + baseUrl: setup.baseUrl, + networkScope: setup.networkScope, + urlConfirmed: !setup.requiresUrlConfirmation, + secrets: {} as Partial>, + } satisfies CustomWidgetSourceSetupValue, + ]), + ); +} + +export function isCustomWidgetSourceSetupReady( + setups: readonly CustomWidgetSourceSetup[], + values: Record, +) { + return setups.every((setup) => { + const value = values[setup.sourceId]; + return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed); + }); +} + +export function CustomWidgetSourceSetupPanel({ + setups, + values, + messages, + onChange, +}: CustomWidgetSourceSetupPanelProps) { + if (setups.length === 0) return null; + return ( + + }> + + {messages.title} + + {messages.description} + + {setups.map((setup) => { + const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId]; + if (!value) return null; + const issue = getCustomWidgetSourceSetupIssue(value); + const missingCredentials = setup.credentialFields.filter( + (field) => !field.configured && !value.secrets[field.kind]?.trim(), + ); + const urlReady = !issue && value.urlConfirmed; + return ( + + +
+ {setup.sourceName} + + {setup.sourceId} + +
+ + : } + > + {urlReady ? messages.ready : messages.needsUrl} + + {missingCredentials.length > 0 && ( + }> + {messages.credentialsMissing} + + )} + +
+ + {messages.suggestedUrl}: {setup.suggestedBaseUrl} + + + onChange(setup.sourceId, { ...value, baseUrl: event.currentTarget.value, urlConfirmed: true }) + } + /> + + } + autoComplete="off" + onChange={(event) => + onChange(setup.sourceId, { + ...value, + secrets: { ...value.secrets, [field.kind]: event.currentTarget.value }, + }) + } + /> + ); + })} + {missingCredentials.length > 0 && ( + + {messages.credentialsOptional} + + )} +
+ ); + })} +
+ ); +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6d1e37642..0cf00c925 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -703,14 +703,16 @@ }, "customWidget": { "secretEntry": { - "title": "Configure credential", - "description": "The value is encrypted by Homarr and is never returned to the requesting agent.", + "title": "Configure API source", + "description": "Confirm the server URL and enter credentials. Secret values are encrypted by Homarr and never returned to the requesting agent.", "loading": "Loading secure request…", - "saved": "Credential saved. You can close this page.", + "saved": "API source configured. You can close this page.", "unavailable": "Request unavailable", - "saveError": "Unable to save credentials", + "saveError": "Unable to configure API source", "source": "API source: {name}", - "save": "Save encrypted credential", + "baseUrl": "Server URL", + "networkScope": "Network access", + "save": "Save API configuration", "expires": "This one-time request expires at {time}.", "field": { "apiKey": "API key or token", @@ -1089,6 +1091,16 @@ "password": "Password" } }, + "sourceSetup": { + "title": "Configure API sources", + "description": "Confirm where this widget connects and add credentials. Private and loopback URLs are specific to your Homarr installation.", + "suggestedUrl": "Suggested URL", + "confirmUrl": "I confirm that this server URL is correct for my Homarr installation", + "ready": "URL ready", + "needsUrl": "Confirm URL", + "credentialsMissing": "Credentials missing", + "credentialsOptional": "You can install without credentials and configure them later, but requests will fail until they are added." + }, "actionWarning": { "title": "This widget can change remote data", "description": "Review every named action, inherited credential, and destructive confirmation after import. Secrets are not included in the file." @@ -1147,6 +1159,8 @@ "descriptionField": "Description", "screenshots": "Screenshots", "screenshotsDescription": "Up to five PNG, JPEG, or WebP images", + "privateSourceWarning": "This widget contains private source URLs for: {sources}. Replace personal hosts or IP addresses with safe service placeholders before publishing.", + "privateSourceConfirmation": "I reviewed these source URLs and they do not expose private infrastructure details", "action": "Publish now", "error": "Publication failed" }, diff --git a/packages/workshop/package.json b/packages/workshop/package.json index 627105ae8..06e595211 100644 --- a/packages/workshop/package.json +++ b/packages/workshop/package.json @@ -14,6 +14,7 @@ "exports": { ".": "./src/index.ts", "./client": "./src/client.ts", + "./backend": "./src/backend.ts", "./schema": "./src/schema.ts" }, "scripts": { @@ -25,7 +26,9 @@ }, "dependencies": { "@homarr/custom-widgets": "workspace:^0.1.0", + "@tanstack/react-query": "catalog:", "pocketbase": "catalog:", + "react": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/workshop/src/backend.ts b/packages/workshop/src/backend.ts new file mode 100644 index 000000000..a95c38d07 --- /dev/null +++ b/packages/workshop/src/backend.ts @@ -0,0 +1,834 @@ +import PocketBase, { ClientResponseError } from "pocketbase"; +import type { RecordService } from "pocketbase"; +import { + keepPreviousData, + mutationOptions, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core"; +import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; + +import type { + WorkshopComment, + WorkshopReport, + WorkshopSubmissionDetail, + WorkshopSubmissionInput, + WorkshopSubmissionSummary, + WorkshopSubmissionType, + WorkshopUser, + WorkshopVote, +} from "./schema"; +import { + WORKSHOP_API_URL, + WORKSHOP_REQUEST_TIMEOUT_MS, + workshopCommentSchema, + workshopReportSchema, + workshopSubmissionDetailSchema, + workshopSubmissionSummarySchema, + workshopSubmissionInputSchema, + workshopScreenshotsSchema, + workshopUserSchema, + workshopVoteSchema, + WORKSHOP_CSS_SCHEMA, +} from "./schema"; + +export interface WorkshopListOptions { + page?: number; + perPage?: number; + sort?: "top" | "newest" | "recent" | "discussed"; + search?: string; + author?: string; + type?: WorkshopSubmissionType | "all"; + includeOutdated?: boolean; + signal?: AbortSignal; +} + +export interface WorkshopPage { + page: number; + perPage: number; + totalPages: number; + totalItems: number; + items: T[]; +} + +interface WorkshopBaseRecord { + id: string; + collectionId: string; + collectionName: string; +} + +export interface WorkshopUserRecord extends WorkshopBaseRecord { + email: string; + emailVisibility: boolean; + verified: boolean; + username: string; + displayName: string; + avatarUrl: string; + avatar: string; + githubUsername: string; + githubProfileUrl: string; + isAdmin: boolean; + created: string; + updated: string; +} + +export interface WorkshopSubmissionRecord extends WorkshopBaseRecord { + type: WorkshopSubmissionType; + title: string; + description: string; + widgetSchema: string; + content: string; + revision: number; + changelog: string; + outdated: boolean; + screenshots: string[]; + author: string; + created: string; + updated: string; + expand?: { author?: WorkshopUserRecord }; +} + +export interface WorkshopListingRecord extends Omit { + screenshots: string[] | string; + authorName: string; + authorAvatarUrl: string; + authorGithubUsername: string; + authorGithubProfileUrl: string; + score: number; + upvotes: number; + downvotes: number; + commentCount: number; + reportCount: number; +} + +export interface WorkshopVoteRecord extends WorkshopBaseRecord { + submission: string; + user: string; + value: 1 | -1; + created: string; + updated: string; +} + +export interface WorkshopReportRecord extends WorkshopBaseRecord { + submission: string; + reporter: string; + category: WorkshopReport["category"]; + explanation: string; + status: WorkshopReport["status"]; + created: string; + updated: string; + expand?: { reporter?: WorkshopUserRecord; submission?: WorkshopSubmissionRecord }; +} + +export interface WorkshopCommentRecord extends WorkshopBaseRecord { + submission: string; + author: string; + content: string; + created: string; + updated: string; + expand?: { author?: WorkshopUserRecord }; +} + +export interface TypedWorkshopPocketBase extends PocketBase { + collection(idOrName: "users"): RecordService; + collection(idOrName: "submissions"): RecordService; + collection(idOrName: "workshop_listings"): RecordService; + collection(idOrName: "votes"): RecordService; + collection(idOrName: "reports"): RecordService; + collection(idOrName: "comments"): RecordService; + collection(idOrName: string): RecordService; +} + +const requestSignal = (signal?: AbortSignal) => + AbortSignal.any( + signal + ? [signal, AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)] + : [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)], + ); + +function oauthText(...values: unknown[]) { + return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim(); +} + +function githubProfile(meta: Record | undefined, record: { id: string }) { + const rawUser = + meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record) : undefined; + const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login); + const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url); + const githubUsername = oauthText(meta?.username, rawUser?.login); + return { + displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100), + avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined, + githubUsername, + githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : undefined, + }; +} + +const timestamp = (value: string) => (Number.isNaN(Date.parse(value)) ? 0 : Date.parse(value)); + +function sortListings(items: WorkshopSubmissionSummary[], sort: WorkshopListOptions["sort"]) { + return items.toSorted((left, right) => { + if (sort === "newest") return timestamp(right.created) - timestamp(left.created); + if (sort === "recent") return timestamp(right.updated) - timestamp(left.updated); + if (sort === "discussed") + return right.commentCount - left.commentCount || timestamp(right.created) - timestamp(left.created); + return right.score - left.score || timestamp(right.created) - timestamp(left.created); + }); +} + +function filterListings(items: WorkshopSubmissionSummary[], options: WorkshopListOptions) { + const search = options.search?.trim().toLocaleLowerCase(); + return sortListings( + items.filter((item) => { + if (options.type && options.type !== "all" && item.type !== options.type) return false; + if (options.author && item.author !== options.author) return false; + if (options.includeOutdated === false && item.outdated) return false; + if (search && !`${item.title}\n${item.description}\n${item.authorName}`.toLocaleLowerCase().includes(search)) + return false; + return true; + }), + options.sort, + ); +} + +const listingSort = (sort: WorkshopListOptions["sort"]) => { + if (sort === "newest") return "-created"; + if (sort === "recent") return "-updated"; + if (sort === "discussed") return "-commentCount,-created"; + return "-score,-created"; +}; + +function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopListOptions) { + const filters: string[] = []; + if (options.type && options.type !== "all") filters.push(pocketBase.filter("type = {:type}", { type: options.type })); + if (options.search?.trim()) { + filters.push( + pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorName ~ {:search})", { + search: options.search.trim(), + }), + ); + } + if (options.author) filters.push(pocketBase.filter("author = {:author}", { author: options.author })); + if (options.includeOutdated === false) filters.push("outdated = false"); + return filters.join(" && "); +} + +const isViewCompatibilityError = (error: unknown) => + error instanceof ClientResponseError && (error.status === 400 || error.status === 404); + +const enrichAuthor = (row: unknown) => { + if (typeof row !== "object" || row === null || Array.isArray(row)) return row; + const record = row as Record; + const expand = record.expand; + const author = + typeof expand === "object" && expand !== null && !Array.isArray(expand) + ? (expand as Record).author + : undefined; + const user = + typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record) : {}; + const githubUsername = record.authorGithubUsername || user.githubUsername || user.username; + return { + ...record, + authorName: record.authorName || user.displayName || user.username, + authorAvatarUrl: record.authorAvatarUrl || user.avatarUrl, + authorGithubUsername: githubUsername, + authorGithubProfileUrl: + record.authorGithubProfileUrl || + user.githubProfileUrl || + (typeof githubUsername === "string" && githubUsername + ? `https://github.com/${encodeURIComponent(githubUsername)}` + : undefined), + }; +}; + +const parseListings = (rows: unknown[]) => + rows.flatMap((row) => { + const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row)); + return result.success ? [result.data] : []; + }); + +function workshopError(error: unknown, fallback: string) { + const rawMessage = error instanceof Error ? error.message : String(error ?? ""); + if (/popup.*block|blocked.*popup/iu.test(rawMessage)) + return new Error("GitHub sign-in popup was blocked. Allow popups for this site and try again.", { cause: error }); + if (/cancel|closed.*popup|popup.*closed/iu.test(rawMessage)) + return new Error("GitHub sign-in was cancelled before it completed.", { cause: error }); + if ( + /provider|oauth|redirect|callback|client.?id/iu.test(rawMessage) && + /invalid|missing|not found|failed/iu.test(rawMessage) + ) + return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error }); + if (error instanceof ClientResponseError) { + const sdkMessage = error.data?.message || error.message; + const originalMessage = + error.originalError instanceof Error ? error.originalError.message : String(error.originalError?.message ?? ""); + const message = !sdkMessage || sdkMessage === "Something went wrong." ? originalMessage || fallback : sdkMessage; + return Object.assign(new Error(message, { cause: error }), { status: error.status }); + } + if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) + return new Error(`${fallback}. The request timed out.`, { cause: error }); + return error instanceof Error ? error : new Error(fallback); +} + +export class WorkshopBackend { + public readonly pocketBase: TypedWorkshopPocketBase; + + public constructor(public readonly baseUrl = WORKSHOP_API_URL) { + this.pocketBase = new PocketBase(baseUrl.replace(/\/$/u, "")) as TypedWorkshopPocketBase; + this.pocketBase.autoCancellation(false); + } + + public get currentUser(): WorkshopUser | null { + const record = this.pocketBase.authStore.record; + if (!record) return null; + return workshopUserSchema.parse({ + id: record.id, + displayName: record.displayName || record.name || record.username || "Community member", + avatarUrl: record.avatarUrl || (record.avatar ? this.pocketBase.files.getURL(record, record.avatar) : ""), + githubUsername: record.githubUsername || record.username || "", + githubProfileUrl: record.githubProfileUrl || "", + isAdmin: record.isAdmin === true, + }); + } + + public subscribeToAuth(listener: (user: WorkshopUser | null) => void) { + return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true); + } + + public async refreshAuth() { + if (!this.pocketBase.authStore.isValid) return null; + try { + await this.pocketBase.collection("users").authRefresh(); + return this.currentUser; + } catch (error) { + if (error instanceof ClientResponseError && (error.status === 401 || error.status === 403)) { + this.pocketBase.authStore.clear(); + return null; + } + return this.currentUser; + } + } + + public async signInWithGitHub() { + try { + const auth = await this.pocketBase.collection("users").authWithOAuth2({ + provider: "github", + createData: { displayName: "GitHub user" }, + }); + const profile = githubProfile(auth.meta, auth.record); + const updated = await this.pocketBase.collection("users").update(auth.record.id, profile); + this.pocketBase.authStore.save(auth.token, updated); + return this.currentUser; + } catch (error) { + throw workshopError(error, "GitHub sign-in failed"); + } + } + + public signOut() { + this.pocketBase.authStore.clear(); + } + + public async list(options: WorkshopListOptions = {}): Promise> { + try { + const page = options.page ?? 1; + const perPage = options.perPage ?? 24; + try { + const result = await this.pocketBase.collection("workshop_listings").getList(page, perPage, { + filter: listingFilter(this.pocketBase, options), + sort: listingSort(options.sort), + signal: requestSignal(options.signal), + }); + return { ...result, items: parseListings(result.items) }; + } catch (error) { + if (!isViewCompatibilityError(error)) throw error; + const items = filterListings(await this.loadListingsFallback(options.signal), options); + return { + page, + perPage, + totalItems: items.length, + totalPages: Math.ceil(items.length / perPage), + items: items.slice((page - 1) * perPage, page * perPage), + }; + } + } catch (error) { + throw workshopError(error, "Failed to load Workshop submissions"); + } + } + + public async listAll(options: Omit = {}) { + try { + try { + const rows = await this.pocketBase.collection("workshop_listings").getFullList({ + batch: 200, + filter: listingFilter(this.pocketBase, options), + sort: listingSort(options.sort), + signal: requestSignal(options.signal), + }); + return parseListings(rows); + } catch (error) { + if (!isViewCompatibilityError(error)) throw error; + return filterListings(await this.loadListingsFallback(options.signal), options); + } + } catch (error) { + throw workshopError(error, "Failed to load Workshop submissions"); + } + } + + private async loadListingsFallback(signal?: AbortSignal) { + const options = { batch: 200, signal: requestSignal(signal) }; + const rows = await this.pocketBase + .collection("workshop_listings") + .getFullList(options) + .catch((error: unknown) => { + if (isViewCompatibilityError(error)) + return this.pocketBase.collection("submissions").getFullList({ ...options, expand: "author" }); + throw error; + }); + return parseListings(rows); + } + + public async get(id: string, signal?: AbortSignal): Promise { + try { + const submission = await this.pocketBase + .collection("submissions") + .getOne(id, { signal: requestSignal(signal), expand: "author" }); + const listing = await this.pocketBase + .collection("workshop_listings") + .getOne(id, { signal: requestSignal(signal) }) + .catch((error: unknown) => { + if (error instanceof ClientResponseError && (error.status === 400 || error.status === 404)) return {}; + throw error; + }); + return workshopSubmissionDetailSchema.parse( + enrichAuthor({ ...submission, ...listing, expand: submission.expand, content: submission.content }), + ); + } catch (error) { + throw workshopError(error, "Submission not found"); + } + } + + public fileUrl(submissionId: string, filename: string, thumb?: string) { + return `${this.baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}${thumb ? `?thumb=${encodeURIComponent(thumb)}` : ""}`; + } + + public async create(input: WorkshopSubmissionInput, screenshots: File[] = []) { + const parsed = workshopSubmissionInputSchema.parse(input); + workshopScreenshotsSchema.parse(screenshots); + const data = new FormData(); + Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value))); + data.set("widgetSchema", parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA); + data.set("author", this.currentUser?.id ?? ""); + data.set("revision", "1"); + screenshots.forEach((file) => data.append("screenshots", file)); + try { + const result = await this.pocketBase.collection("submissions").create(data); + return this.get(result.id); + } catch (error) { + throw workshopError(error, "Failed to publish submission"); + } + } + + public async update(id: string, input: WorkshopSubmissionInput, screenshots: File[] = []) { + const parsed = workshopSubmissionInputSchema.parse(input); + workshopScreenshotsSchema.parse(screenshots); + const current = await this.get(id); + const data = new FormData(); + Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value))); + data.set("widgetSchema", parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA); + data.set("revision", String(current.revision + 1)); + screenshots.forEach((file) => data.append("screenshots", file)); + try { + await this.pocketBase.collection("submissions").update(id, data); + return this.get(id); + } catch (error) { + throw workshopError(error, "Failed to update submission"); + } + } + + public async delete(id: string) { + try { + await this.pocketBase.collection("submissions").delete(id); + } catch (error) { + throw workshopError(error, "Failed to delete submission"); + } + } + + public async vote(submission: string, value: 1 | -1): Promise { + const user = this.currentUser?.id ?? ""; + const filter = this.pocketBase.filter("submission = {:submission} && user = {:user}", { submission, user }); + try { + const existing = await this.pocketBase + .collection("votes") + .getFirstListItem(filter) + .catch((error: unknown) => + error instanceof ClientResponseError && error.status === 404 ? null : Promise.reject(error), + ); + if (existing?.value === value) { + await this.pocketBase.collection("votes").delete(existing.id); + return null; + } + const record = existing + ? await this.pocketBase.collection("votes").update(existing.id, { value }) + : await this.pocketBase.collection("votes").create({ submission, user, value }); + return workshopVoteSchema.parse(record); + } catch (error) { + throw workshopError(error, "Failed to save vote"); + } + } + + public async listVotesForCurrentUser(): Promise { + const user = this.currentUser?.id; + if (!user) return []; + try { + const rows = await this.pocketBase.collection("votes").getFullList({ + filter: this.pocketBase.filter("user = {:user}", { user }), + }); + return rows.map((row) => workshopVoteSchema.parse(row)); + } catch (error) { + throw workshopError(error, "Failed to load votes"); + } + } + + public async report(submission: string, category: WorkshopReport["category"], explanation: string) { + try { + return workshopReportSchema.parse( + await this.pocketBase.collection("reports").create({ + submission, + reporter: this.currentUser?.id ?? "", + category, + explanation: explanation.trim(), + status: "open", + }), + ); + } catch (error) { + throw workshopError(error, "Failed to report submission"); + } + } + + public async listReports(): Promise { + try { + const items = await this.pocketBase + .collection("reports") + .getFullList({ batch: 200, filter: "status = 'open'", sort: "-created", expand: "reporter,submission" }); + return items.map((item) => + workshopReportSchema.parse({ + ...item, + reporterName: item.expand?.reporter?.displayName, + submissionTitle: item.expand?.submission?.title, + }), + ); + } catch (error) { + throw workshopError(error, "Failed to load reports"); + } + } + + public async dismissReport(id: string) { + try { + await this.pocketBase.collection("reports").update(id, { status: "dismissed" }); + } catch (error) { + throw workshopError(error, "Failed to dismiss report"); + } + } + + public async toggleOutdated(id: string, outdated: boolean) { + try { + await this.pocketBase.collection("submissions").update(id, { outdated }); + return this.get(id); + } catch (error) { + throw workshopError(error, "Failed to update submission status"); + } + } + + public async listComments(submission: string): Promise { + try { + const rows = await this.pocketBase.collection("comments").getFullList({ + filter: this.pocketBase.filter("submission = {:submission}", { submission }), + sort: "created", + expand: "author", + }); + return rows.map((row) => + workshopCommentSchema.parse({ + ...row, + authorName: row.expand?.author?.displayName, + authorAvatarUrl: row.expand?.author?.avatarUrl, + authorGithubProfileUrl: row.expand?.author?.githubProfileUrl, + }), + ); + } catch (error) { + throw workshopError(error, "Failed to load comments"); + } + } + + public async createComment(submission: string, content: string): Promise { + try { + const row = await this.pocketBase + .collection("comments") + .create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" }); + return workshopCommentSchema.parse({ + ...row, + authorName: row.expand?.author?.displayName, + authorAvatarUrl: row.expand?.author?.avatarUrl, + authorGithubProfileUrl: row.expand?.author?.githubProfileUrl, + }); + } catch (error) { + throw workshopError(error, "Failed to post comment"); + } + } + + public async updateComment(id: string, content: string): Promise { + try { + const row = await this.pocketBase + .collection("comments") + .update(id, { content: content.trim() }, { expand: "author" }); + return workshopCommentSchema.parse({ + ...row, + authorName: row.expand?.author?.displayName, + authorAvatarUrl: row.expand?.author?.avatarUrl, + authorGithubProfileUrl: row.expand?.author?.githubProfileUrl, + }); + } catch (error) { + throw workshopError(error, "Failed to update comment"); + } + } + + public async deleteComment(id: string) { + try { + await this.pocketBase.collection("comments").delete(id); + } catch (error) { + throw workshopError(error, "Failed to delete comment"); + } + } +} + +const listKeyOptions = (options: WorkshopListOptions) => ({ + page: options.page, + perPage: options.perPage, + sort: options.sort, + search: options.search, + author: options.author, + type: options.type, + includeOutdated: options.includeOutdated, +}); + +export const workshopQueryKeys = { + all: ["workshop"] as const, + lists: (baseUrl?: string) => [...workshopQueryKeys.all, "list", baseUrl] as const, + list: (backend: WorkshopBackend, options: WorkshopListOptions) => + [...workshopQueryKeys.lists(backend.baseUrl), listKeyOptions(options)] as const, + details: (baseUrl?: string) => [...workshopQueryKeys.all, "detail", baseUrl] as const, + detail: (backend: WorkshopBackend, id: string) => [...workshopQueryKeys.details(backend.baseUrl), id] as const, + reports: (backend: WorkshopBackend) => [...workshopQueryKeys.all, "reports", backend.baseUrl] as const, + comments: (backend: WorkshopBackend, submission: string) => + [...workshopQueryKeys.all, "comments", backend.baseUrl, submission] as const, +}; + +export const workshopListQueryOptions = (backend: WorkshopBackend, options: WorkshopListOptions = {}) => + queryOptions({ + queryKey: workshopQueryKeys.list(backend, options), + queryFn: ({ signal }) => backend.list({ ...options, signal }), + placeholderData: keepPreviousData, + }); + +export const workshopSubmissionQueryOptions = (backend: WorkshopBackend, id: string) => + queryOptions({ + queryKey: workshopQueryKeys.detail(backend, id), + queryFn: ({ signal }) => backend.get(id, signal), + enabled: id.length > 0, + }); + +export const workshopReportsQueryOptions = (backend: WorkshopBackend) => + queryOptions({ queryKey: workshopQueryKeys.reports(backend), queryFn: () => backend.listReports() }); + +export const workshopCommentsQueryOptions = (backend: WorkshopBackend, submission: string) => + queryOptions({ + queryKey: workshopQueryKeys.comments(backend, submission), + queryFn: () => backend.listComments(submission), + enabled: submission.length > 0, + }); + +export const workshopCreateMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ input, screenshots = [] }: { input: WorkshopSubmissionInput; screenshots?: File[] }) => + backend.create(input, screenshots), + }); + +export const workshopUpdateMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ + id, + input, + screenshots = [], + }: { + id: string; + input: WorkshopSubmissionInput; + screenshots?: File[]; + }) => backend.update(id, input, screenshots), + }); + +export const workshopDeleteMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ mutationFn: (id: string) => backend.delete(id) }); + +export const workshopVoteMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ submission, value }: { submission: string; value: 1 | -1 }) => backend.vote(submission, value), + }); + +export const workshopReportMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ + submission, + category, + explanation, + }: Pick) => + backend.report(submission, category, explanation), + }); + +export const workshopDismissReportMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ mutationFn: (id: string) => backend.dismissReport(id) }); + +export const workshopToggleOutdatedMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ id, outdated }: { id: string; outdated: boolean }) => backend.toggleOutdated(id, outdated), + }); + +export const workshopCreateCommentMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ submission, content }: { submission: string; content: string }) => + backend.createComment(submission, content), + }); + +export const workshopUpdateCommentMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ id, content }: { id: string; content: string }) => backend.updateComment(id, content), + }); + +export const workshopDeleteCommentMutationOptions = (backend: WorkshopBackend) => + mutationOptions({ + mutationFn: ({ id }: { id: string; submission: string }) => backend.deleteComment(id), + }); + +export function useWorkshopQuery(backend: WorkshopBackend, options: WorkshopListOptions = {}) { + return useQuery(workshopListQueryOptions(backend, options)); +} + +export function useWorkshopSubmissionQuery(backend: WorkshopBackend, id: string) { + return useQuery(workshopSubmissionQueryOptions(backend, id)); +} + +export function useWorkshopReportsQuery(backend: WorkshopBackend) { + return useQuery(workshopReportsQueryOptions(backend)); +} + +export function useWorkshopCommentsQuery(backend: WorkshopBackend, submission: string) { + return useQuery(workshopCommentsQueryOptions(backend, submission)); +} + +function useWorkshopInvalidation(backend: WorkshopBackend) { + const queryClient = useQueryClient(); + return { + lists: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.lists(backend.baseUrl) }), + details: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.details(backend.baseUrl) }), + detail: (id: string) => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.detail(backend, id) }), + removeDetail: (id: string) => queryClient.removeQueries({ queryKey: workshopQueryKeys.detail(backend, id) }), + reports: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reports(backend) }), + comments: (submission: string) => + queryClient.invalidateQueries({ queryKey: workshopQueryKeys.comments(backend, submission) }), + }; +} + +export function useWorkshopCreateMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ ...workshopCreateMutationOptions(backend), onSuccess: invalidation.lists }); +} + +export function useWorkshopUpdateMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopUpdateMutationOptions(backend), + onSuccess: async (submission) => Promise.all([invalidation.lists(), invalidation.detail(submission.id)]), + }); +} + +export function useWorkshopDeleteMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopDeleteMutationOptions(backend), + onSuccess: async (_result, id) => { + invalidation.removeDetail(id); + await invalidation.lists(); + }, + }); +} + +export function useWorkshopVoteMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopVoteMutationOptions(backend), + onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details()]), + }); +} + +export function useWorkshopReportMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopReportMutationOptions(backend), + onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.reports()]), + }); +} + +export function useWorkshopDismissReportMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopDismissReportMutationOptions(backend), + onSuccess: async () => Promise.all([invalidation.reports(), invalidation.lists(), invalidation.details()]), + }); +} + +export function useWorkshopToggleOutdatedMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopToggleOutdatedMutationOptions(backend), + onSuccess: async (submission) => Promise.all([invalidation.lists(), invalidation.detail(submission.id)]), + }); +} + +export function useWorkshopCreateCommentMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopCreateCommentMutationOptions(backend), + onSuccess: async (_comment, input) => + Promise.all([invalidation.comments(input.submission), invalidation.lists(), invalidation.details()]), + }); +} + +export function useWorkshopUpdateCommentMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopUpdateCommentMutationOptions(backend), + onSuccess: (comment) => invalidation.comments(comment.submission), + }); +} + +export function useWorkshopDeleteCommentMutation(backend: WorkshopBackend) { + const invalidation = useWorkshopInvalidation(backend); + return useMutation({ + ...workshopDeleteCommentMutationOptions(backend), + onSuccess: async (_result, input) => + Promise.all([invalidation.comments(input.submission), invalidation.lists(), invalidation.details()]), + }); +} + +export function useWorkshopWidgetImportMutation( + install: ((widget: HomarrCustomWidgetV2) => Promise) | undefined, + onSuccess?: () => void, +) { + return useMutation({ mutationFn: async (widget: HomarrCustomWidgetV2) => install?.(widget), onSuccess }); +} + +export function useWorkshopCssImportMutation(useCss: ((css: string) => void) | undefined, onSuccess?: () => void) { + return useMutation({ mutationFn: async (css: string) => useCss?.(css), onSuccess }); +} diff --git a/packages/workshop/src/client.spec.ts b/packages/workshop/src/client.spec.ts index 4be5fa232..f00392d5d 100644 --- a/packages/workshop/src/client.spec.ts +++ b/packages/workshop/src/client.spec.ts @@ -1,7 +1,10 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + autoCancellation: vi.fn(), authWithOAuth2: vi.fn(), + getFullList: vi.fn(), + getList: vi.fn(), update: vi.fn(), })); @@ -18,19 +21,39 @@ vi.mock("pocketbase", () => { public collection() { return mocks; } + + public autoCancellation(enabled: boolean) { + mocks.autoCancellation(enabled); + return this; + } + + public filter(template: string, values: Record) { + return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`); + } } return { default: MockPocketBase, - ClientResponseError: class ClientResponseError extends Error {}, + ClientResponseError: class ClientResponseError extends Error { + public constructor(public readonly status = 0) { + super("PocketBase request failed"); + } + }, }; }); -import { WorkshopClient } from "./client"; +import { ClientResponseError } from "pocketbase"; +import type { RecordService } from "pocketbase"; -describe("WorkshopClient OAuth", () => { +import { WorkshopBackend } from "./backend"; +import type { WorkshopSubmissionRecord } from "./backend"; + +describe("WorkshopBackend", () => { beforeEach(() => { + mocks.autoCancellation.mockReset(); mocks.authWithOAuth2.mockReset(); + mocks.getFullList.mockReset(); + mocks.getList.mockReset(); mocks.update.mockReset(); }); @@ -42,7 +65,7 @@ describe("WorkshopClient OAuth", () => { }); mocks.update.mockResolvedValue({ id: "user-id", displayName: "octocat" }); - const client = new WorkshopClient("https://workshop.example.com"); + const client = new WorkshopBackend("https://workshop.example.com"); await client.signInWithGitHub(); expect(mocks.authWithOAuth2).toHaveBeenCalledWith({ @@ -52,4 +75,90 @@ describe("WorkshopClient OAuth", () => { expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey"); expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback"); }); + + test("exposes the typed submission collection service", () => { + const client = new WorkshopBackend("https://workshop.example.com"); + expectTypeOf(client.pocketBase.collection("submissions")).toEqualTypeOf>(); + expect(mocks.autoCancellation).toHaveBeenCalledWith(false); + }); + + test("uses PocketBase filtering and pagination when the listing view is current", async () => { + mocks.getList.mockResolvedValue({ + page: 1, + perPage: 12, + totalPages: 1, + totalItems: 1, + items: [listingRecord()], + }); + + const client = new WorkshopBackend("https://workshop.example.com"); + const result = await client.list({ type: "customWidget", sort: "top", page: 1, perPage: 12 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ type: "customWidget", score: 0, revision: 1 }); + expect(mocks.getList).toHaveBeenCalledWith( + 1, + 12, + expect.objectContaining({ filter: "type = 'customWidget'", sort: "-score,-created" }), + ); + expect(mocks.getFullList).not.toHaveBeenCalled(); + }); + + test("searches listing titles, descriptions, and authors", async () => { + mocks.getList.mockResolvedValue({ + page: 1, + perPage: 12, + totalPages: 1, + totalItems: 1, + items: [listingRecord()], + }); + + const client = new WorkshopBackend("https://workshop.example.com"); + await client.list({ search: "active streams", page: 1, perPage: 12 }); + + expect(mocks.getList).toHaveBeenCalledWith( + 1, + 12, + expect.objectContaining({ + filter: "(title ~ 'active streams' || description ~ 'active streams' || authorName ~ 'active streams')", + }), + ); + }); + + test("retries an incomplete listing view without missing-field filters", async () => { + mocks.getList.mockRejectedValueOnce(new ClientResponseError(400)); + mocks.getFullList.mockResolvedValueOnce([listingRecord()]); + + const client = new WorkshopBackend("https://workshop.example.com"); + const result = await client.list({ type: "customWidget" }); + + expect(result.items).toHaveLength(1); + expect(mocks.getFullList).toHaveBeenCalledWith( + expect.not.objectContaining({ filter: expect.anything(), sort: expect.anything() }), + ); + }); + + test("falls back to base submissions when an old listing view itself returns 400", async () => { + mocks.getList.mockRejectedValueOnce(new ClientResponseError(400)); + mocks.getFullList.mockRejectedValueOnce(new ClientResponseError(400)).mockResolvedValueOnce([listingRecord()]); + + const client = new WorkshopBackend("https://workshop.example.com"); + const result = await client.list({ type: "customWidget" }); + + expect(result.items).toHaveLength(1); + expect(mocks.getFullList).toHaveBeenCalledTimes(2); + }); }); + +function listingRecord() { + return { + id: "submission-id", + title: "Tautulli Activity", + description: "Shows active streams", + widgetSchema: "homarr-custom-widget-v2", + screenshots: [], + author: "author-id", + created: "2026-07-21 14:19:25.790Z", + updated: "2026-07-21 14:19:25.790Z", + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51522ff57..ede5aaa32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1374,6 +1374,9 @@ importers: '@homarr/validation': specifier: workspace:^0.1.0 version: link:../validation + '@homarr/workshop': + specifier: workspace:^0.1.0 + version: link:../workshop '@kubernetes/client-node': specifier: 'catalog:' version: 1.4.0 @@ -2880,9 +2883,15 @@ importers: '@homarr/custom-widgets': specifier: workspace:^0.1.0 version: link:../custom-widgets + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.101.2(react@19.2.7) pocketbase: specifier: 'catalog:' version: 0.26.9 + react: + specifier: 'catalog:' + version: 19.2.7 zod: specifier: 'catalog:' version: 4.4.3