Compare commits

...
Author SHA1 Message Date
ajnart a648a9fdff fix(workshop): harden deployment and deep links 2026-08-08 17:35:10 +02:00
ajnart 74fa51a461 refactor: consolidate custom widget admin routes 2026-07-30 17:51:52 +02:00
ajnart 48438fc003 fix: satisfy custom widget architecture limit 2026-07-30 16:32:50 +02:00
ajnart 657704340c refactor: trim v2 release hardening 2026-07-30 16:24:11 +02:00
ajnart 3cf1dc8341 test: decouple workshop contracts from ci 2026-07-30 15:22:27 +02:00
ajnart 2eb0baf17e refactor: trim v2 hardening scaffolding 2026-07-30 15:19:17 +02:00
ajnart ecfb9d59e0 test: make custom widget value selector exact 2026-07-30 14:46:05 +02:00
ajnart 93c95e1846 refactor: rely on PocketBase rate limiting 2026-07-30 14:29:09 +02:00
ajnart 1ce69d78f0 test: clarify AI invalidation feedback 2026-07-30 12:39:10 +02:00
ajnart 6d1ab12788 test: make AI scenario matching order independent 2026-07-30 12:20:15 +02:00
ajnart 5cd40b5c53 test: strengthen custom widget AI evaluation 2026-07-30 12:04:27 +02:00
ajnart 45dd20f4f7 test: address custom widget review findings 2026-07-30 11:34:24 +02:00
ajnart 6eff1da08f test: harden custom widget AI authoring 2026-07-30 11:09:17 +02:00
ajnart 2f0c0a07dd fix legacy migration redaction assertion 2026-07-30 08:09:32 +02:00
ajnart 944c674939 streamline AI import and Workshop errors 2026-07-30 08:00:47 +02:00
ajnart 7a2d6009f7 simplify v2 migration history 2026-07-29 20:19:47 +02:00
ajnart 6ae109e8f5 tighten workshop artifact check 2026-07-28 22:51:11 +02:00
ajnart 98d56264da simplify workshop submission storage 2026-07-28 22:31:58 +02:00
ajnart 6f957ee984 fix e2e selectors 2026-07-28 15:49:17 +02:00
ajnart a7eae3ff81 fix v2 hardening review findings 2026-07-28 15:28:58 +02:00
ajnart b82a6a7abc fix workshop corpus resolution 2026-07-28 14:40:06 +02:00
ajnart 72caa31b57 fix v2 release CI failures 2026-07-28 14:31:43 +02:00
ajnart 4ac9d21bc1 fix v2 release gate determinism 2026-07-28 14:19:34 +02:00
ajnart 723b46926a harden v2 custom widgets and workshop 2026-07-28 14:12:14 +02:00
139 changed files with 5215 additions and 1977 deletions

No files matched your search

+20 -3
View File
@@ -61,9 +61,26 @@ TURBO_TELEMETRY_DISABLED=1
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public Workshop API origin used by Homarr's Workshop browser. The default is https://homarr.dev.
# This is read when the server starts, including in the production Docker image.
# WORKSHOP_API_URL=http://127.0.0.1:3003
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# query strings, or fragments. Trailing slashes are normalized at startup.
HOMARR_WEBSITE_URL=https://homarr.dev
# PocketBase API used by Homarr and by the standalone documentation site.
# Defaults to HOMARR_WEBSITE_URL.
# WORKSHOP_API_URL=https://homarr.dev
# Public Workshop page opened from Homarr. Defaults to HOMARR_WEBSITE_URL/workshop.
# WORKSHOP_WEB_URL=https://homarr.dev/workshop
# Documentation and PocketBase deployment settings.
# WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
# PB_ALLOWED_ORIGINS=*
# Local Workshop stack:
# HOMARR_WEBSITE_URL=http://127.0.0.1:3003
# WORKSHOP_API_URL=http://127.0.0.1:8090
# WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
# WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
# Optional: GITHUB_CLIENT_ID=
# Optional: GITHUB_CLIENT_SECRET=
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
# DEMO_MODE=true
+8 -2
View File
@@ -21,8 +21,14 @@ pnpm dev:docs
# Or from within this directory
pnpm dev
# Point the docs at a local PocketBase instance
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
```
The standalone docs use `WORKSHOP_API_URL` for PocketBase and `HOMARR_WEBSITE_URL` for the public site origin. The old
`WORKSHOP_URL` name is a deprecated one-release alias.
## Build
```bash
@@ -40,7 +46,7 @@ Uses oxlint and oxfmt (same as the rest of the monorepo):
```bash
pnpm lint # oxlint
pnpm format # oxfmt (check)
pnpm format -- --write # oxfmt (fix)
pnpm format:fix # oxfmt (fix)
```
## Typecheck
@@ -93,7 +99,7 @@ pnpm verify:search
| Layer | Technology |
| -------------- | ------------------------------------------- |
| Site generator | Docusaurus 3 (`@docusaurus/preset-classic`) |
| Styling | Tailwind CSS 3 + Docusaurus Infima |
| Styling | Tailwind CSS 4 + Docusaurus Infima |
| Icons | `@tabler/icons-react` |
| Diagrams | Mermaid (`@docusaurus/theme-mermaid`) |
| Search | Algolia DocSearch |
@@ -8,62 +8,107 @@ tags:
sidebar_position: 1
---
## Setting up your development environment
This guide will help you set up your development environment to start contributing to homarr.
## Set up your development environment
### Prerequisites
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **A GitHub account**: You can create one on the [official website](https://github.com/).
- Node.js 24.18 or newer
- Corepack and pnpm 11.11
- Git
- Docker with Docker Compose
### Setting up the project
The versions in the root `package.json` are the supported baseline. Let Corepack select the repository version instead
of installing an unrelated global pnpm:
1. Fork the repository by clicking on the "Fork" button on the top right corner of the repository page.
```sh
corepack enable
corepack install
pnpm install --frozen-lockfile
```
![Fork button](./img/fork.png)
### Run the Homarr application
2. Clone the repository to your local machine with `git clone`. You can find the url to use by clicking on the "Code" button on the top right corner of your forks repository page.
1. Copy `.env.example` to `.env`.
2. Set `DB_URL` to an absolute writable SQLite file path.
3. Keep Redis and the optional local databases running in one terminal.
4. Apply migrations and start Next.js in another terminal.
![Clone button](./img/clone.png)
```sh
cp .env.example .env
```
3. Change directory to the project folder with `cd homarr`.
Terminal 1:
4. Install the dependencies with `pnpm install`.
```sh
pnpm docker:dev
```
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
Terminal 2:
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
```sh
pnpm db:migration:sqlite:run
pnpm dev
```
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
The infrastructure command remains in the foreground. Open `http://127.0.0.1:3000`; the first user created through
Homarr becomes the instance administrator.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
### Run PocketBase and documentation
9. Open your browser and navigate to [http://localhost:3000](http://localhost:3000). (Loading the page might take up to 2 minutes the first time)
Start PocketBase in another terminal:
### Useful npm scripts
```sh
cp apps/workshop/.env.example apps/workshop/.env
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
```
#### During development
Then start Docusaurus in a third terminal:
- `pnpm dev`: Start the development server.
- `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo.
```sh
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
```
#### Checking code quality
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
- `pnpm lint`: Run the linter to check for code quality issues.
- `pnpm format:fix`: Run the formatter to fix code style issues.
- `pnpm test`: Run the tests. You can also use `pnpm test:ui` to get a UI for the tests.
- `pnpm typecheck`: Run the typescript type checker.
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
`http://127.0.0.1:3003/workshop`.
#### Building the project
Point Homarr at the local services with:
- `pnpm build`: Build the project for production.
- `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
```dotenv
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
```
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
### URL testing
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
separate API origin before release:
```sh
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
WORKSHOP_API_URL=http://127.0.0.1:8090 \
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
pnpm dev:docs
```
### Useful commands
- `pnpm dev` — Next.js on port 3000.
- `pnpm dev:docs` — Docusaurus only on port 3003.
- `pnpm docker:workshop` — local PocketBase through Docker Compose.
- `pnpm cli` — Homarr operations CLI.
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
- `pnpm db:studio` — inspect the database.
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
- `pnpm test:workshop` — disposable Workshop integration suite.
- `pnpm build` — production monorepo build.
@@ -25,13 +25,16 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
:::
| Environment Variable | Description | Possible values | Default |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| Environment Variable | Description | Possible values | Default |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
## Authentication
@@ -105,7 +108,12 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing.
:::
| Environment Variable | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
| `WORKSHOP_API_URL` | Runtime public origin of the separate Workshop PocketBase service. Homarr uses `https://homarr.dev` by default. |
| Environment Variable | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. |
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence.
@@ -4,6 +4,9 @@ An external agent creates one widget at a time with the Homarr MCP server. The a
Homarr does not create widget packs or drafts for multiple widgets.
The MCP API key must belong to a Homarr administrator. Non-administrator keys do not list Custom Widget tools, prompts,
or resources. The same administrator requirement applies to the matching HTTP authoring endpoints.
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
View the Custom Widget Skill on skills.sh
</a>
@@ -67,7 +70,10 @@ These interfaces do not return an archive URL or archive hash.
12. Update the saved definition if the final verification finds a problem.
13. Repeat the procedure for the next widget.
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.
Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and
can only be opened and completed by an authenticated Homarr administrator. The administrator 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:
@@ -88,8 +94,11 @@ Homarr maintainers can run the same self-contained prompt that the workbench cop
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
```
The suite uses DeepSeek V4 Pro to author five complex widgets and DeepSeek V4 Flash to judge only widgets that pass Homarr's real parser and canonical schema. It saves every response, validated `widget.json`, `widget.jsx`, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
The suite uses DeepSeek V4 Pro to author Pokédex, Portainer, football, Jellyfin, and Home Assistant widgets. Before DeepSeek V4 Flash judges a result, it must pass Homarr's real clipboard parser, canonical schema, and deterministic scenario checks for the documented API routes, authentication, bindings, actions, and essential runtime components. It saves every response, validated widget manifest, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
The run has a hard budget of ten generation attempts across all scenarios. Override the models or budget with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and `CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result into the bundled-widget source deliberately.
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the models or
per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and
`CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result
into the bundled-widget source deliberately.
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
@@ -27,11 +27,12 @@ Every option has a default. Supported controls include text, textarea, number, s
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
The model must return exactly:
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
without any placeholder replacement or second paste.
1. One fenced `json` block with `template: "__HOMARR_TEMPLATE__"`.
2. One fenced `jsx` block with the readable template.
Select **Paste AI response**. Homarr fills the visual builders and shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading, empty, failure, and success states.
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading,
empty, failure, and success states.
Connected agents should use the skills.sh skill and retrieve the live schema and individual component metadata only as needed.
@@ -38,7 +38,8 @@ Bounded regex literals are supported for string matching, splitting, search, and
- `SubFetch` runs a manual query with its `params`. Set `triggerContent` to a card or image to make that content the accessible click and keyboard trigger instead of showing the default load button.
- `ActionButton` and `ToggleSwitch` run actions.
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries.
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries for authenticated viewers. It
is disabled for anonymous public-board viewers because refreshing invalidates shared server cache state.
- `Icon`/`TablerIcon` resolves installed safe icons.
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
@@ -49,6 +49,21 @@ interface HomarrCustomWidgetV2 {
The browser renders JSX. Homarr's server executes named requests, applies board permissions and network protections, and injects credentials. The browser never receives source credentials.
## Permissions
Custom Widget management is an administrator-only surface. Only a Homarr administrator can create, import, migrate,
preview, publish, install, configure, or write secrets, including through HTTP authoring resources or MCP. An API key
only lists Custom Widget MCP tools, prompts, and resources when its owner is an administrator.
Adding or configuring a Custom Widget placement requires both administrator access and permission to modify that
board. A non-administrator with board-modify access can move, resize, change presentation settings, or remove an
existing placement, but cannot add one, change its definition, refresh interval, or Custom Widget options. Duplicating
a board that contains Custom Widgets also requires an administrator.
Once an administrator has placed a widget, ordinary board permissions apply to rendering and requests. Authorized
non-administrators can use it on private boards, and queries can render for anonymous viewers on public boards.
Mutations remain authenticated and enforce each request's view, modify, or full permission.
## Authoring paths
- [Create a Custom Widget](./authoring) visually or with one copied AI prompt.
@@ -60,9 +75,26 @@ The browser renders JSX. Homarr's server executes named requests, applies board
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the legacy format remain visible with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. Homarr keeps the legacy record until the replacement validates and is confirmed; credentials are never included in the prompt.
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
prompt**, give the redacted prompt to an LLM, copy its single complete JSON block, then select **Paste migrated widget**
from that widget's action menu. Homarr reads the `template` value into the JSX editor automatically. The prompt keeps safe
request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
prompt.
When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required.
Homarr keeps the original v1 definition and encrypted secret as a compatibility backup even after the validated v2
replacement is created. The v2 record uses the same stable ID and takes precedence. The archived v1 copy stays hidden
while the replacement exists; deleting the v2 record surfaces the v1 entry as migration-required again. Automatic
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
logs, or exports.
For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. 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
@@ -70,10 +102,25 @@ Import review shows origins, authentication modes, network scopes, methods, acti
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.
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
performs definitive validation again at install time.
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.
These tools are only exposed to API keys owned by Homarr administrators. Opening and completing a source-configuration
URL also requires the same authenticated administrator; the URL is short-lived, single-use, and redacted.
## Reliability and rollback
Custom Widget response caches and action invalidation use shared Redis state, so multiple Homarr replicas see the same
versioned cache generation. Deployments must point every replica at the same Redis service.
Before upgrading, back up the database and `SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables
during application rollback. Widgets created only in v2 may be unavailable to the rolled-back binary, but do not
replace the v1 backup.
## Bundled examples
Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public contract. The Tautulli example is the highest-scoring widget preserved from Homarr's AI authoring evaluation:
@@ -12,7 +12,9 @@ Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `pe
The request `path` is appended to the source `baseUrl`, including a base path such as `/api/v2`.
GET, POST, PUT, PATCH, and DELETE work for queries and actions. Kind—not method—controls behavior. Load queries run automatically, manual queries run through `SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
GET, POST, PUT, and PATCH work for queries and actions. DELETE is restricted to actions so it can never run while a
board loads. Kind controls the remaining behavior: load queries run automatically, manual queries run through
`SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
Bind values directly where they are used:
@@ -40,3 +42,12 @@ Paths and query-string values must resolve to text, numbers, or booleans. JSON b
Every request uses Homarr's existing server executor: same-origin source enforcement, network scope, DNS and redirect validation, SSRF protection, board permissions, rate and request limits, body and response limits, timeouts, and encrypted credential injection. Static headers cannot override credentials, cookies, routing, proxy, forwarding, or hop-by-hop headers.
Queries default to view permission and actions to modify. DELETE always requires full permission and confirmation. Actions are never cached, retried, or run on load.
Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and
configures the widget. After placement, query and action authorization follows the request's board permission. Public
boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache
invalidation: `RefreshButton` is disabled for anonymous viewers, while ordinary scheduled query rendering remains
available.
Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every
Homarr replica stops serving the previous response without scanning or deleting a key prefix.
@@ -4,7 +4,16 @@
The definition does not exist. If it was deleted, remove the unavailable tile from the board.
If the Custom Widgets page shows the definition with a **Migration required** badge, open its action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON from the same menu. Homarr keeps the legacy definition until the replacement validates and you confirm the migration.
If the Custom Widgets page shows the definition with a **Migration required** badge, an administrator can open its
action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON
from the same menu. The LLM returns one complete JSON block; copy it once and select **Paste migrated widget**. Homarr
places its `template` value into the JSX editor automatically. Because arbitrary v1 display text or JSX may contain
credentials, the prompt omits that display configuration; describe the old widget's visible layout and behavior without
including secrets. Other viewers see **Contact an administrator** instead of a migration link.
Homarr keeps the v1 definition and encrypted secret after migration. The validated v2 replacement uses the same ID and
takes precedence; the archived v1 record reappears if the v2 replacement is deleted. There is no v1 permanent-delete
action during the v2 stabilization period.
Otherwise, install or create a Custom JSX v2 definition.
@@ -34,6 +43,11 @@ An invalid definition disables widget preview and Save/Create. The Data, Options
The Diagnostics tab shows the current schema errors.
## Workshop unavailable
If Workshop is unavailable, check `WORKSHOP_API_URL` and PocketBase health. Widgets that are already installed continue
to work.
## Correct request errors
Check the API source network scope. Check the base URL and credentials.
@@ -44,6 +58,9 @@ For “invalid placeholder,” use `{option:name}` or `{param:name}`. Load queri
The journal shows the method, request ID, path template, status, and duration.
In a multi-instance deployment, all Homarr replicas must share Redis. Versioned cache invalidation is distributed
through Redis; replicas connected to different Redis databases can serve stale query responses after an action.
## Correct a Mantine prop warning
`UNKNOWN_MANTINE_PROP` is a warning. Homarr passes the prop to Mantine.
@@ -57,3 +74,10 @@ Use safe widget options, temporary inputs, or named requests instead.
The widget option definitions changed and the saved values are invalid. Open the item settings.
Correct the marked configuration values. Homarr does not infer widget option migrations.
## Roll back safely
Before an application upgrade, back up the database and preserve the exact `SECRET_ENCRYPTION_KEY`. The v2 schema keeps
the physical v1 definition and secret tables readable by a v1 binary. Roll back the application without running
manual cleanup, verify legacy widgets, and restore the database backup if the migration itself must be reversed.
PocketBase Workshop data is a separate backup and restore operation.
+16 -2
View File
@@ -92,6 +92,10 @@ MCP supports two authentication methods:
API key authentication is simpler and recommended for most setups. The API key inherits the permissions of the user who created it. Tools that require higher privileges will return permission errors if the key lacks access.
Custom Widget and Workshop authoring are different: their tools, prompts, and resources are exposed only when the API
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
API key never elevates the owner.
## Testing the Connection
Verify your MCP endpoint is working:
@@ -168,6 +172,9 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
An agent creates one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`.
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
secrets, publication, Workshop installation, and the HTTP authoring resources.
Then, read the live schema and component resources. These resources describe the installed Mantine components.
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
@@ -194,7 +201,8 @@ Discovery resources:
- `homarr://custom-widgets/examples/{name}`
- `homarr://custom-widgets/skill`
An HTTP-only harness can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, and `/skill` endpoints instead of MCP resource URIs.
An authenticated administrator can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`,
and `/skill` endpoints instead of MCP resource URIs. These endpoints are not public schema downloads.
The Custom Widget tools are:
@@ -219,7 +227,13 @@ The Custom Widget tools are:
- `customWidget_getAuthoringPrompt`
- `customWidget_getSkill`
The `custom-widget-manage` permission permits authoring. A direct credential write also requires the `custom-widget-secret-write` permission.
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
authoring surface. A source-configuration URL is short-lived and single-use, requires the same logged-in administrator
to open and complete it, and never returns plaintext credentials to the agent.
Adding a `customApi` item through `board_addItem`, changing its definition or options, and duplicating a board that
contains one also require an administrator with board-modify access. Existing placements continue to render and run
authorized requests according to normal board permissions.
Homarr applies its request security controls during request execution. These controls include SSRF, DNS, redirect, size, timeout, rate, and permission controls.
@@ -177,6 +177,11 @@ Currently, the following permissions are available:
- **Full integration access**: Allow members to manage, use and interact with any integration
- **Administrator**: Members with this permission have full access to all features and settings
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
presentation settings, or remove an existing Custom Widget placement, and board viewers can use placed widgets as
allowed by each request.
When you changed permissions, you must confirm your changes by clicking the banner at the bottom:
![](./img/changing-group-permissions.png)
+42 -10
View File
@@ -17,9 +17,15 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing,
publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**.
:::warning
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
Back up your instance before installing untrusted content.
:::
@@ -29,12 +35,12 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
- Report details and reporter identities are visible only to Workshop administrators. Reported submissions are highlighted in the administration page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
- Report details and reporter identities are visible only to Workshop moderators. Reported submissions are highlighted in the moderation page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop administrators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
## Submitting
@@ -49,7 +55,14 @@ Comments are publicly readable and appear in chronological conversation bubbles
Submissions are published immediately. Reported entries are reviewed and may be removed.
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop administrator receives an email when a report is submitted. If an administrator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
:::info Separate roles
A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community
and is unrelated to a Homarr instance administrator.
:::
## Submission guidelines
@@ -61,9 +74,9 @@ that primarily promotes unrelated products or services.
You can install workshop entries without leaving Homarr:
- **Custom widgets** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
@@ -74,13 +87,32 @@ Custom Widget imports detect required bearer, basic, header, or query credential
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Run `pnpm docker:workshop` for the PocketBase development service or `pnpm docker:docs` for the built documentation and Workshop site. Both use `apps/workshop/docker-compose.yml` and the persistent `pb_data` volume. Normal start and stop commands retain data; `docker compose -f apps/workshop/docker-compose.yml down --volumes` intentionally recreates the database.
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
irreversible development reset.
Because Workshop is still unreleased, reset an older development volume if it predates the current schema and comments report that their collection context is missing. No compatibility migrations are maintained for these pre-release databases.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
links and email use the public host. Workshop uses the OAuth provider username as the public display name instead of
the profile's full name. Configure SMTP for comment, report, and removal notices.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices.
The public URL contract is:
Homarr instances use the server-side `WORKSHOP_API_URL` variable to reach the central API. The Docusaurus site uses `WORKSHOP_URL` when its development server runs separately; the combined production image always uses its own origin. Restart the corresponding process after changing either value because both are read at startup.
| Variable | Purpose | Default |
| ------------------------ | ------------------------------------------------- | ----------------------------- |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Restart
services after changing these values.
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
## Client architecture
+2 -1
View File
@@ -38,7 +38,8 @@ Enable the definition before you use it. The definition must also be available t
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
Queries and actions can use GET, POST, PUT, PATCH, or DELETE. The `kind` value controls runtime behavior.
Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run
automatically while a board loads. The `kind` value controls the remaining runtime behavior.
A DELETE request requires full board permission and confirmation. An anonymous viewer cannot run an action.
+61 -2
View File
@@ -1,19 +1,29 @@
import type * as Preset from "@docusaurus/preset-classic";
import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
const a11yEmoji = require("@fec/remark-a11y-emoji");
const deprecatedWorkshopUrl = process.env.WORKSHOP_URL;
if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead.");
}
const publicUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL,
workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl,
});
const config: Config = {
title: "Homarr documentation",
tagline: "A simple yet powerful dashboard for your server.",
url: "https://homarr.dev",
url: publicUrls.homarrWebsiteUrl,
baseUrl: "/",
trailingSlash: undefined,
favicon: "img/logo.png",
organizationName: "homarr-labs",
projectName: "homarr",
customFields: {
workshopUrl: process.env.WORKSHOP_URL ?? "",
workshopUrl: publicUrls.workshopApiUrl,
},
i18n: {
defaultLocale: "en",
@@ -335,6 +345,55 @@ const config: Config = {
function workshopRoutesPlugin() {
return {
name: "workshop-routes",
injectHtmlTags() {
return {
headTags: [
{
tagName: "style",
innerHTML: `
#workshop-detail-route-fallback { display: none; }
html[data-workshop-detail-loading] #__docusaurus { display: none; }
html[data-workshop-detail-loading] #workshop-detail-route-fallback {
align-items: center;
background: #fff;
box-sizing: border-box;
color: #4b5563;
display: flex;
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
@media (prefers-color-scheme: dark) {
html[data-workshop-detail-loading] #workshop-detail-route-fallback {
background: #18191a;
color: #d1d5db;
}
}`,
},
{
tagName: "script",
innerHTML: `(function () {
var match = window.location.pathname.match(/^\\/workshop\\/([^/]+)\\/?$/);
if (match && match[1] !== "admin") {
document.documentElement.setAttribute("data-workshop-detail-loading", "");
}
})();`,
},
],
preBodyTags: [
{
tagName: "div",
attributes: {
id: "workshop-detail-route-fallback",
role: "status",
"aria-live": "polite",
},
innerHTML: "Loading Workshop…",
},
],
};
},
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
@@ -84,7 +84,6 @@ export function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={colorMode}>
@@ -103,19 +102,11 @@ export function BundledCustomWidgetGalleryClient() {
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
label="Complete widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
value={JSON.stringify(selected.widget, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
height="680px"
/>
</Stack>
</MantineProvider>
@@ -4,6 +4,7 @@ import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import {
IconArrowBigDown,
IconArrowBigUp,
@@ -335,7 +336,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (!submission) return;
try {
const updated = await backend.toggleOutdated(submission.id, !submission.outdated);
setSubmission({ ...submission, outdated: updated.outdated });
setSubmission(updated);
setError(null);
toast.success(updated.outdated ? "Submission marked as outdated" : "Submission marked as current");
} catch (caught) {
@@ -453,7 +454,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
setReportOpen(false);
setReportExplanation("");
setError(null);
toast.success("Report submitted", { description: "A Workshop administrator can now review it." });
toast.success("Report submitted", { description: "A Workshop moderator can now review it." });
} catch (caught) {
setReportError(errorMessage(caught, "Failed to submit report"));
} finally {
@@ -863,7 +864,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<DialogHeader>
<DialogTitle>Report submission</DialogTitle>
<DialogDescription>
Only Workshop administrators can review report details and reporter identities.
Only Workshop moderators can review report details and reporter identities.
</DialogDescription>
</DialogHeader>
{reportError && (
@@ -1017,6 +1018,10 @@ export default function MarketplaceDetailPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
useEffect(() => {
document.documentElement.removeAttribute("data-workshop-detail-loading");
}, []);
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
@@ -52,7 +52,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
setSubmissions(nextSubmissions);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
setError(cause instanceof Error ? cause.message : "Unable to load Workshop moderation");
} finally {
setLoading(false);
}
@@ -118,7 +118,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<IconShield size={18} />
<span className="text-sm font-medium">Restricted area</span>
</div>
<h1 className="text-3xl font-semibold tracking-tight">Workshop administration</h1>
<h1 className="text-3xl font-semibold tracking-tight">Workshop moderation</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.
</p>
@@ -145,14 +145,14 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
{user && !user.isAdmin && (
<Alert variant="destructive" className="mb-5">
<IconShield />
<AlertTitle>Administrator access required</AlertTitle>
<AlertTitle>Workshop moderator access required</AlertTitle>
<AlertDescription>This GitHub account cannot access Workshop moderation.</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive" className="mb-5">
<IconFlag />
<AlertTitle>Workshop administration failed</AlertTitle>
<AlertTitle>Workshop moderation failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
+1 -2
View File
@@ -3,12 +3,11 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
export default function WorkshopAdminRoute() {
const { siteConfig } = useDocusaurusContext();
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop administration" noFooter>
<Layout title="Workshop moderation" noFooter>
<WorkshopAdmin workshopUrl={workshopUrl} />
</Layout>
);
+37 -17
View File
@@ -1,28 +1,48 @@
# Create T3 App
# Homarr web application
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
This workspace contains Homarr's Next.js application. It consumes the shared API, authentication, database, board,
widget, translation, and UI packages from this monorepo.
## What's next? How do I make an app with this?
Run commands from the repository root:
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
```sh
pnpm install --frozen-lockfile
cp .env.example .env
```
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
Keep infrastructure running in the first terminal:
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Drizzle](https://orm.drizzle.team)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
```sh
pnpm docker:dev
```
## Learn More
Apply migrations and start the application in a second terminal:
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
```sh
pnpm db:migration:sqlite:run
pnpm dev
```
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_URL`, `AUTH_SECRET`, and
`SECRET_ENCRYPTION_KEY` in `.env` before first startup.
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
To test Custom Widgets against a local Community Workshop, start PocketBase and the docs in separate terminals:
## How do I deploy this?
```sh
pnpm docker:workshop
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
```
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
Then set:
```dotenv
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
```
Server-rendered runtime configuration exposes those URLs to the browser, so changing a production container's
environment does not require rebuilding the Next.js bundle.
See [`apps/docs/docs/advanced/development/getting-started.mdx`](../docs/docs/advanced/development/getting-started.mdx)
for the full contributor setup.
@@ -9,6 +9,7 @@ import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useSession } from "@homarr/auth/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
@@ -22,6 +23,8 @@ interface Props {
}
export const CustomCssSettingsContent = ({ board }: Props) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
@@ -48,7 +51,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
</Alert>
<Group justify="space-between">
<WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />
{isAdmin ? <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} /> : <span />}
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -1,4 +1,7 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { CustomWidgetConfigurationEntry } from "./configuration-entry";
@@ -8,6 +11,8 @@ export const metadata: Metadata = {
};
export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { token } = await params;
return <CustomWidgetConfigurationEntry token={token} />;
}
+9 -3
View File
@@ -24,7 +24,7 @@ import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
@@ -54,7 +54,7 @@ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr Dashboard",
description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
url: "https://homarr.dev",
url: env.HOMARR_WEBSITE_URL,
siteName: "Homarr",
},
icons: {
@@ -89,6 +89,11 @@ export default async function Layout(props: {
const serverSettings = await getServerSettingsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const publicUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const StackedProvider = composeWrappers([
(innerProps) => {
@@ -141,7 +146,8 @@ export default async function Layout(props: {
suppressHydrationWarning
>
<head>
<meta name="homarr-workshop-api-url" content={env.WORKSHOP_API_URL ?? WORKSHOP_API_URL} />
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -18,7 +18,7 @@ describe("Copy AI prompt", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("one complete `jsx` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
@@ -5,6 +5,7 @@ import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBuildingStore,
IconClipboard,
IconCopy,
IconDots,
IconDownload,
@@ -38,7 +39,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const deleteLegacyMutation = clientApi.customWidget.deleteLegacy.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const utils = clientApi.useUtils();
const [publishOpened, publishControls] = useDisclosure(false);
@@ -62,22 +62,37 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
}
};
const queueMigratedWidget = (text: string) => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
};
const pasteMigratedWidget = async () => {
try {
queueMigratedWidget(await navigator.clipboard.readText());
} catch {
showErrorNotification({
title: t("action.migrate"),
message: t("notification.migrationError"),
});
}
};
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
try {
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
queueMigratedWidget(loadEvent.target?.result as string);
} catch {
showErrorNotification({
title: t("action.migrate"),
@@ -146,10 +161,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
};
if (widget.migrationRequired) {
deleteLegacyMutation.mutate({ id: widget.id }, callbacks);
return;
}
deleteMutation.mutate({ id: widget.id }, callbacks);
},
});
@@ -187,6 +198,9 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
{t("action.copyMigrationPrompt")}
</Menu.Item>
<Menu.Item onClick={() => void pasteMigratedWidget()} leftSection={<IconClipboard {...iconProps} />}>
{t("action.pasteMigration")}
</Menu.Item>
<Menu.Item
onClick={() => migrationFileInputRef.current?.click()}
leftSection={<IconUpload {...iconProps} />}
@@ -196,14 +210,16 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Divider />
</>
)}
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending || deleteLegacyMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
{!widget.migrationRequired && (
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
@@ -26,6 +26,9 @@ export function CustomWidgetAiCard(props: AiCardProps) {
<IconSparkles size={18} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
applyCustomWidgetAiResponse,
filterSecretsForSourceAuthentication,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
@@ -23,6 +24,40 @@ const definition = customWidgetDefinitionSchema.parse({
});
describe("Custom Widget workbench preview options", () => {
it("loads one complete AI response into every editor field, including JSX", () => {
const setValues = vi.fn();
const result = applyCustomWidgetAiResponse(
{
values: {
name: "",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "",
secrets: [],
},
setValues,
} as never,
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
);
if (!result.success) throw new Error("Expected the complete AI response to be accepted");
expect(setValues).toHaveBeenCalledOnce();
const values = setValues.mock.calls[0]?.[0];
expect(values).toMatchObject({
name: "Preview options",
description: "",
iconUrl: "",
template: "<Text>Preview</Text>",
secrets: [],
});
expect(JSON.parse(values?.sources as string)).toEqual(result.widget.sources);
expect(JSON.parse(values?.requests as string)).toEqual(result.widget.requests);
expect(JSON.parse(values?.options as string)).toEqual(result.widget.options);
});
it("shows the default source first regardless of manifest key order", () => {
expect(
parseSources(
@@ -3,6 +3,7 @@ import {
customWidgetDefinitionSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetRequiredSecretKinds,
parseCustomWidgetAiResponse,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -56,6 +57,12 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
});
}
export function applyCustomWidgetAiResponse(form: CustomWidgetWorkbenchForm, response: string) {
const result = parseCustomWidgetAiResponse(response);
if (result.success) applyDefinition(form, result.widget);
return result;
}
export function parseSources(value: string): Array<CustomWidgetSource & { id: string }> {
const parsed = parseJson(value);
return isRecord(parsed)
@@ -12,6 +12,8 @@ describe("legacy custom widget migration UI", () => {
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
@@ -6,17 +6,13 @@ import { useRouter } from "next/navigation";
import type { UseFormReturnType } from "@mantine/form";
import { clientApi } from "@homarr/api/client";
import {
customWidgetDefinitionSchema,
formatCustomWidgetImportIssues,
parseCustomWidgetAiResponse,
} from "@homarr/custom-widgets/core";
import { formatCustomWidgetImportIssues } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import {
applyDefinition,
applyCustomWidgetAiResponse,
buildDefinition,
getChangedSecrets,
getDefinitionDefaults,
@@ -167,11 +163,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const pasteAiResponse = async () => {
try {
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
const result = applyCustomWidgetAiResponse(input.form, await navigator.clipboard.readText());
if (!result.success) {
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
@@ -16,10 +16,9 @@ interface EditCustomWidgetPageProps {
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
const session = await auth();
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets");
}
const params = await props.params;
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
@@ -11,10 +11,9 @@ import { FormErrorBoundary } from "../_form-error-boundary";
export default async function NewCustomWidgetPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets");
}
const t = await getScopedI18n("customWidget");
return (
@@ -17,10 +17,9 @@ import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
if (!session || !session.user.permissions.includes("admin")) {
redirect(session ? "/" : "/auth/login");
}
const definitions = await api.customWidget.list();
const t = await getScopedI18n("customWidget");
@@ -7,7 +7,7 @@ import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user.permissions.includes("custom-widget-manage")) redirect("/");
if (!session?.user.permissions.includes("admin")) redirect("/");
const { id } = await params;
const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null);
if (!preview) notFound();
@@ -79,13 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
hidden: !session?.user.permissions.includes("custom-widget-manage"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("custom-widget-manage"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
@@ -1,17 +1,41 @@
import { redirect } from "next/navigation";
import { Button } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
import { env } from "~/env";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("custom-widget-manage")) redirect(session ? "/" : "/auth/login");
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
const { workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
return (
<ManagePageLayout title={t("title")} size="xl">
<ManagePageLayout
title={t("title")}
size="xl"
primaryAction={
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
>
{t("openCommunity")}
</Button>
}
>
<WorkshopInstaller />
</ManagePageLayout>
);
@@ -0,0 +1,42 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import { getCustomWidgetComponentCatalog, getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { adminRoute } from "../admin";
interface RouteContext {
params: Promise<{ resource: string }>;
}
const resources = new Map<string, () => unknown>([
["schema", getCustomWidgetJsonSchema],
["components", getCustomWidgetComponentCatalog],
["skill", getCustomWidgetSkill],
[
"authoring-prompt",
() => ({
version: 2,
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
resources: [
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
}),
],
]);
const getAuthoringResource = async (_request: NextRequest, context: RouteContext): Promise<Response> => {
const { resource } = await context.params;
const loadResource = resources.get(resource);
if (!loadResource) {
return NextResponse.json({ error: "Custom Widget authoring resource not found." }, { status: 404 });
}
return NextResponse.json(loadResource());
};
export const GET = adminRoute(getAuthoringResource);
@@ -0,0 +1,37 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
auth: vi.fn(),
}));
vi.mock("@homarr/auth/next", () => ({ auth: mocks.auth }));
import { adminRoute } from "./admin";
beforeEach(() => {
mocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
});
describe("Custom Widget HTTP resource access", () => {
test("allows authenticated administrators", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const response = await adminRoute(handler)();
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledOnce();
});
test.each([
["anonymous users", null],
["authenticated non-admins", { user: { permissions: ["board-modify-all"] } }],
])("denies %s", async (_label, session) => {
mocks.auth.mockResolvedValue(session);
const handler = vi.fn(async () => Response.json({ ok: true }));
const response = await adminRoute(handler)();
expect(response.status).toBe(403);
expect(handler).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { auth } from "@homarr/auth/next";
type RouteHandler<Arguments extends unknown[]> = (...args: Arguments) => Response | Promise<Response>;
export const adminRoute =
<Arguments extends unknown[]>(handler: RouteHandler<Arguments>) =>
async (...args: Arguments): Promise<Response> => {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return NextResponse.json({ error: "Administrator access is required." }, { status: 403 });
}
return handler(...args);
};
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json({
version: 2,
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
resources: [
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
});
}
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetComponentCatalog());
}
@@ -4,25 +4,23 @@ import { NextResponse } from "next/server";
import {
claimCustomWidgetConfigurationRequest,
completeCustomWidgetConfigurationRequest,
configureCustomWidgetSourceFromRequest,
configurePreviewSessionSource,
getCustomWidgetConfigurationRequest,
parseStoredCustomWidgetDefinition,
releaseCustomWidgetConfigurationRequest,
serializeCustomWidgetDefinition,
} from "@homarr/api/custom-widget-configuration";
import { encryptSecret } from "@homarr/common/server";
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { db } from "@homarr/db";
import { adminRoute } from "../../admin";
import { readConfigurationRequestBody } from "../body";
interface RouteContext {
params: Promise<{ token: string }>;
}
export async function GET(_request: NextRequest, context: RouteContext) {
const getConfigurationRequest = async (_request: NextRequest, context: RouteContext): Promise<Response> => {
const { token } = await context.params;
const request = await getCustomWidgetConfigurationRequest(token);
if (!request)
@@ -38,9 +36,9 @@ export async function GET(_request: NextRequest, context: RouteContext) {
},
{ headers: { "Cache-Control": "no-store" } },
);
}
};
export async function POST(request: NextRequest, context: RouteContext) {
const completeConfigurationRequest = async (request: NextRequest, context: RouteContext): Promise<Response> => {
const { token } = await context.params;
const pending = await getCustomWidgetConfigurationRequest(token);
if (!pending || pending.status !== "pending") {
@@ -94,19 +92,24 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
} else {
const definition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, claimed.target.id),
const result = await configureCustomWidgetSourceFromRequest(db, {
definitionId: claimed.target.id,
sourceId: claimed.sourceId,
baseUrl: sourceResult.data.baseUrl,
networkScope: sourceResult.data.networkScope,
secrets,
expectedSource: claimed.source,
});
if (!definition) return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
const parsed = parseStoredCustomWidgetDefinition(definition);
const currentSource = parsed.sources[claimed.sourceId];
if (!currentSource) {
if (result.status === "definition-not-found") {
return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
}
if (result.status === "source-not-found") {
return NextResponse.json(
{ error: "This API source changed after the setup link was created. Request a new setup link." },
{ status: 409 },
);
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, claimed.source)) {
if (result.status === "binding-changed") {
return NextResponse.json(
{
error: "This API source authentication changed after the setup link was created. Request a new setup link.",
@@ -114,69 +117,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
{ status: 409 },
);
}
const configuredSource = customWidgetSourceSchema.parse({
...currentSource,
baseUrl: sourceResult.data.baseUrl,
networkScope: sourceResult.data.networkScope,
});
const definitionChanges = {
...serializeCustomWidgetDefinition({
...parsed,
sources: { ...parsed.sources, [claimed.sourceId]: configuredSource },
}),
updatedAt: new Date(),
};
const secretRows = secrets.map((secret) => ({
definitionId: claimed.target.id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, claimed.target.id));
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, claimed.target.id),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, claimed.target.id))
.run();
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, claimed.target.id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
});
}
await completeCustomWidgetConfigurationRequest(token);
@@ -184,4 +124,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
} finally {
await releaseCustomWidgetConfigurationRequest(token);
}
}
};
export const GET = adminRoute(getConfigurationRequest);
export const POST = adminRoute(completeConfigurationRequest);
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetJsonSchema());
}
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetSkill());
}
@@ -4,6 +4,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
ipAddress: vi.fn(() => "127.0.0.1"),
toolProcedure: vi.fn(),
loggerWarn: vi.fn(),
buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
[`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
),
@@ -13,16 +16,16 @@ const mocks = vi.hoisted(() => ({
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({})) },
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate,
}));
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" }));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }),
}));
vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
@@ -36,7 +39,28 @@ vi.mock("@homarr/custom-widgets/core", () => ({
}));
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] }));
vi.mock("../_extract-tools", () => ({
extractMcpTools: () => [
{
name: "board_getAllBoards",
description: "List boards",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
],
}));
import { POST } from "./route";
@@ -48,9 +72,14 @@ interface JsonRpcResponse {
}
beforeEach(() => {
mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } });
mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["admin"] } });
mocks.buildPrompt.mockClear();
mocks.getComponent.mockClear();
mocks.ipAddress.mockClear();
mocks.ipAddress.mockReturnValue("127.0.0.1");
mocks.toolProcedure.mockReset();
mocks.toolProcedure.mockResolvedValue([]);
mocks.loggerWarn.mockClear();
});
async function callMcp(method: string, params: Record<string, unknown> = {}, id = 1) {
@@ -60,6 +89,8 @@ async function callMcp(method: string, params: Record<string, unknown> = {}, id
Accept: "application/json, text/event-stream",
ApiKey: "key.secret",
"Content-Type": "application/json",
"X-Forwarded-For": "198.51.100.10",
"X-Real-IP": "127.0.0.1",
},
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
});
@@ -91,6 +122,7 @@ describe("authenticated MCP prompt protocol", () => {
serverInfo: { name: "homarr", version: "test-version" },
});
expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
expect(mocks.ipAddress).toHaveBeenCalledWith(expect.any(Headers));
});
test("lists and interpolates the custom-widget authoring prompt", async () => {
@@ -178,6 +210,34 @@ describe("authenticated MCP resource protocol", () => {
});
});
describe("non-admin MCP discovery", () => {
test("does not list or expose Custom Widget tools, prompts or resources", async () => {
mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["board-view-all"] } });
const tools = await callMcp("tools/list");
expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([
"board_getAllBoards",
]);
const prompts = await callMcp("prompts/list");
expect(prompts.body.result).toEqual({ prompts: [] });
const resources = await callMcp("resources/list");
expect(resources.body.result).toEqual({ resources: [] });
const templates = await callMcp("resources/templates/list");
expect(templates.body.result).toEqual({ resourceTemplates: [] });
const prompt = await callMcp("prompts/get", { name: "homarr-custom-widget-author" });
expect(prompt.body.result).toBeUndefined();
expect(prompt.body.error).toBeDefined();
const resource = await callMcp("resources/read", { uri: "homarr://custom-widgets/schema" });
expect(resource.body.result).toBeUndefined();
expect(resource.body.error).toBeDefined();
});
});
describe("MCP protocol errors", () => {
test.each([
["prompts/get", { name: "unknown" }],
@@ -193,4 +253,23 @@ describe("MCP protocol errors", () => {
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
});
test("does not log sensitive tool error messages", async () => {
const failure = Object.assign(new Error("request to https://user:secret@example.test/private failed"), {
code: "BAD_GATEWAY",
});
mocks.toolProcedure.mockRejectedValue(failure);
const { body } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
expect(JSON.stringify(body)).not.toContain("user:secret");
expect(mocks.loggerWarn).toHaveBeenCalledWith("MCP tool execution failed", {
tool: "board_getAllBoards",
errorName: "Error",
errorCode: "BAD_GATEWAY",
});
const metadata = mocks.loggerWarn.mock.calls.at(-1)?.[1] as Record<string, unknown>;
expect(metadata).not.toHaveProperty("error");
expect(metadata).not.toHaveProperty("message");
});
});
@@ -85,7 +85,12 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
const callerStorage = new AsyncLocalStorage<ReturnType<typeof mcpRouter.createCaller>>();
interface McpCallerContext {
caller: ReturnType<typeof mcpRouter.createCaller>;
isAdmin: boolean;
}
const callerStorage = new AsyncLocalStorage<McpCallerContext>();
const emptyObjectSchema = {
type: "object" as const,
@@ -113,6 +118,20 @@ function getTools() {
return toolsCache;
}
function getVisibleTools() {
const tools = getTools();
if (!isCustomWidgetMcpEnabled()) {
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
}
return tools;
}
function requireCustomWidgetMcpAdmin() {
if (!isCustomWidgetMcpEnabled()) throw new Error("Resource not found");
}
const isCustomWidgetMcpEnabled = () => Boolean(callerStorage.getStore()?.isAdmin);
const SAFE_ERROR_MESSAGES: Record<string, string> = {
UNAUTHORIZED: "You do not have permission to perform this action",
FORBIDDEN: "Access denied",
@@ -184,19 +203,22 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
const mcpHandler = createMcpHandler(
(server) => {
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
prompts: [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
],
prompts: isCustomWidgetMcpEnabled()
? [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
]
: [],
}));
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
requireCustomWidgetMcpAdmin();
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
return {
description: "Current Homarr Custom Widget authoring workflow.",
@@ -216,36 +238,49 @@ const mcpHandler = createMcpHandler(
});
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: [
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
...customJsxExamples.map((example) => ({
uri: `homarr://custom-widgets/examples/${example.id}`,
name: `Custom Widget example: ${example.title}`,
mimeType: "application/json",
})),
],
resources: isCustomWidgetMcpEnabled()
? [
{
uri: "homarr://custom-widgets/schema",
name: "Custom Widget schema",
mimeType: "application/schema+json",
},
{
uri: "homarr://custom-widgets/components",
name: "Custom Widget components",
mimeType: "application/json",
},
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
...customJsxExamples.map((example) => ({
uri: `homarr://custom-widgets/examples/${example.id}`,
name: `Custom Widget example: ${example.title}`,
mimeType: "application/json",
})),
]
: [],
}));
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
resourceTemplates: [
{
uriTemplate: "homarr://custom-widgets/components/{name}",
name: "Custom Widget component",
description: "Metadata for one installed Custom Widget component.",
mimeType: "application/json",
},
{
uriTemplate: "homarr://custom-widgets/examples/{name}",
name: "Custom Widget example",
description: "One canonical Custom Widget example.",
mimeType: "application/json",
},
],
resourceTemplates: isCustomWidgetMcpEnabled()
? [
{
uriTemplate: "homarr://custom-widgets/components/{name}",
name: "Custom Widget component",
description: "Metadata for one installed Custom Widget component.",
mimeType: "application/json",
},
{
uriTemplate: "homarr://custom-widgets/examples/{name}",
name: "Custom Widget example",
description: "One canonical Custom Widget example.",
mimeType: "application/json",
},
]
: [],
}));
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
requireCustomWidgetMcpAdmin();
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
@@ -271,12 +306,12 @@ const mcpHandler = createMcpHandler(
});
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: getTools(),
tools: getVisibleTools(),
}));
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tools = getTools();
const tools = getVisibleTools();
const tool = tools.find((t) => t.name === name);
if (!tool) {
@@ -286,8 +321,8 @@ const mcpHandler = createMcpHandler(
};
}
const caller = callerStorage.getStore();
if (!caller) {
const callerContext = callerStorage.getStore();
if (!callerContext) {
return {
content: [{ type: "text" as const, text: "Authentication context not available" }],
isError: true,
@@ -295,7 +330,7 @@ const mcpHandler = createMcpHandler(
}
try {
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], caller);
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], callerContext.caller);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }],
@@ -311,7 +346,9 @@ const mcpHandler = createMcpHandler(
const message = sanitizeErrorMessage(error);
logger.warn("MCP tool execution failed", {
tool: name,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
errorCode:
error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
@@ -398,7 +435,9 @@ const handler = async (req: NextRequest) => {
const ctx = createTRPCContext({ session, headers: req.headers });
const caller = mcpRouter.createCaller(ctx);
return callerStorage.run(caller, () => mcpHandler(req as unknown as Request));
return callerStorage.run({ caller, isAdmin: session.user.permissions.includes("admin") }, () =>
mcpHandler(req as unknown as Request),
);
};
export { handler as GET, handler as POST, handler as DELETE };
@@ -30,6 +30,8 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
};
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
@@ -135,9 +137,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
{canDuplicate && (
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
@@ -3,6 +3,7 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -18,12 +19,17 @@ import { WorkshopInstallButton } from "~/components/workshop/workshop-install-bu
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
{ enabled: isAdmin },
);
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -160,61 +166,65 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
/>
))}
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
</Card>
))}
</>
)}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
@@ -49,6 +49,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const t = useI18n();
const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
@@ -76,9 +77,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
}, [canConfigureWidget, currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
@@ -227,13 +229,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
{canConfigureWidget && (
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
)}
</>
)}
@@ -28,6 +28,7 @@ import {
IconBuildingStore,
IconDownload,
IconFlag,
IconRefresh,
IconSearch,
IconThumbDown,
IconThumbUp,
@@ -217,7 +218,20 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={list.isFetching}
onClick={() => void list.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
+17 -1
View File
@@ -1,13 +1,29 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { normalizeHttpUrl } from "@homarr/workshop/schema";
const publicHttpUrl = (variableName: string) =>
z.string().transform((value, context) => {
try {
return normalizeHttpUrl(value, variableName);
} catch (error) {
context.addIssue({
code: "custom",
message: error instanceof Error ? error.message : `${variableName} is invalid`,
});
return z.NEVER;
}
});
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
WORKSHOP_API_URL: z.string().url().optional(),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
WORKSHOP_WEB_URL: publicHttpUrl("WORKSHOP_WEB_URL").optional(),
},
experimental__runtimeEnv: process.env,
});
+12
View File
@@ -0,0 +1,12 @@
# Local PocketBase + Docusaurus development
PB_EXPOSE_PORT=8090
DOCS_EXPOSE_PORT=3003
PB_ALLOWED_ORIGINS=*
WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
# Optional local GitHub OAuth application.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
+6 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.10
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39.9@sha256:c0667fe6c5197ff226ff642120ab1ad870c3f2f27300940a61e5a7fa082b6816
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
@@ -16,13 +16,17 @@ COPY tooling/typescript ./tooling/typescript
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile
FROM dependencies AS website
ARG HOMARR_WEBSITE_URL=https://homarr.dev
ARG WORKSHOP_API_URL=https://homarr.dev
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
ENV PORT=8090
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
ENTRYPOINT ["workshop-entrypoint"]
FROM pocketbase-runtime AS development
+138 -59
View File
@@ -1,105 +1,184 @@
# Homarr Workshop
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
Workshop is the public, community-driven PocketBase service behind the Workshop pages in Homarr's Docusaurus site. It
stores Custom JSX v2 widgets and Custom CSS submissions. Installation-time validation inside Homarr remains
authoritative: public content must be reviewed before it is installed.
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
collections, API rules, and its explicit serve arguments.
The runtime base is PocketBase 0.39.9 pinned by its multi-platform OCI digest in `apps/workshop/Dockerfile`. Update the
version and digest together, then run the Workshop integration and production-image smoke suites.
It intentionally has no approval queue, user bans, moderator roles, reusable widget connections, or remote control over
installed widgets. Submissions publish immediately. Authenticated users can vote, comment, and report; users with
`isAdmin` enabled can dismiss reports or delete any submission or comment.
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
access. Moderators can review private reports, dismiss reports, and remove submissions or comments.
## Local development
## Run locally
From the repository root:
```sh
pnpm docker:workshop
cp apps/workshop/.env.example apps/workshop/.env
pnpm install --frozen-lockfile
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
```
Use `docker compose -f apps/workshop/docker-compose.yml logs -f workshop` for logs and
`docker compose -f apps/workshop/docker-compose.yml stop workshop` to stop it without deleting data.
This starts persistent PocketBase at `http://127.0.0.1:8090`. Start Docusaurus in another terminal:
Compose passes the selected port to PocketBase as `PORT`, so it listens on the same port inside and outside the
container. With the image directly, set both values: `docker run -e PORT=3003 -p 3003:3003 <workshop-image>`.
```sh
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
```
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
Open:
The `users.isAdmin` field defaults to `false`. Collection rules allow account creation only through OAuth without that
field and reject all regular-user attempts to change it. Appoint administrators out of band with a PocketBase
superuser or a deliberate SQLite update; there is no public role-management endpoint or hook.
- PocketBase administration: `http://127.0.0.1:8090/_/`
- Workshop: `http://127.0.0.1:3003/workshop`
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
Create the first PocketBase superuser in the administration UI, or use:
The GitHub OAuth application needs one callback for the Workshop host:
```sh
docker compose -f apps/workshop/docker-compose.yml exec workshop \
pocketbase superuser create you@example.com 'replace-with-a-strong-password' --dir=/pb_data
```
Stopping the Compose process preserves `homarr-workshop_pb_data`. To inspect or stop the service separately:
```sh
docker compose -f apps/workshop/docker-compose.yml logs -f workshop
docker compose -f apps/workshop/docker-compose.yml stop workshop
```
### Pre-release migration reset
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
exception applies only to disposable pre-release volumes and must not be used for production data.
## Connect the Homarr application
Use the same URL contract in the repository-root `.env`, then restart Homarr:
```dotenv
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
PB_ALLOWED_ORIGINS=*
```
```sh
pnpm dev
```
`WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the
documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable
remains a deprecated alias for one release.
## Optional GitHub OAuth
Create a GitHub OAuth application with this callback:
```text
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins do not need to be registered with GitHub. Keep `PB_ALLOWED_ORIGINS=*` (the default) so localhost and arbitrary self-hosted domains can open the central PocketBase OAuth popup. Set a restricted origin list only if it includes every Homarr origin that should use Workshop sign-in.
Set both credentials in `.env`:
Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in the repository-root `.env`. Set
`WORKSHOP_PUBLIC_ORIGIN=https://<workshop-host>` so notification emails link to the public Workshop. Configure the sender
and SMTP transport in PocketBase. PocketBase emails the submission author about new comments, emails every current
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
failures are logged and never reject a comment, report, or deletion.
```dotenv
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
```
GitHub OAuth settings are synchronized from the environment on every PocketBase startup. Restart the Workshop after
rotating either credential; removing one or both values disables the provider instead of retaining stale credentials.
OAuth settings synchronize on every PocketBase startup. Restart after rotation. A production deployment must never
supply only one credential. `PB_ALLOWED_ORIGINS=*` is appropriate for the central community because arbitrary
self-hosted Homarr origins must open its OAuth popup; a restricted list must include every allowed browser origin.
Workshop uses the OAuth provider username as the public display name instead of the profile's full name.
Run the disposable service integration test from the repository root:
## Promote a Workshop moderator
Sign in once through GitHub so the user record exists. In the PocketBase administration UI, open the `users` collection
and set `isAdmin` to `true`. The user must sign in again. Set it back to `false` to remove moderator access.
Regular API rules cannot set this field. Never describe a Workshop moderator as a Homarr administrator: they are
separate roles in separate services.
## Validation and tests
The official browser and shared client validate content with the canonical TypeScript schema and send the matching
schema identifier. PocketBase stores submission content without parsing, normalizing, or validating it; it only owns
revision fields and rejects stale compare-and-swap updates. Homarr validates downloaded content again before
installation, so direct API submissions that bypass the official client can be rejected there.
Run the disposable integration suite:
```sh
pnpm test:workshop
pnpm --filter @homarr/workshop typecheck
pnpm --filter @homarr/docs typecheck
```
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
roles into the normal `homarr-workshop_pb_data` volume.
The integration suite uses a separate Compose project and deletes only its disposable volume.
The Workshop frontend validates submissions with `customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr
validates canonical content again before installation. PocketBase independently enforces publication invariants for
direct API requests, owns schema/revision fields, and rejects stale updates using the submitted expected revision.
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
administrator-only moderation. Each new submission receives one real author-owned upvote. Reports are private to
administrators and are dismissed by changing their status. PocketBase cascade deletion removes related votes, comments,
and reports when a submission is deleted. Authors and administrators can mark a submission outdated without making it
un-installable.
## Backup and restore
## Production docs preview
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
PocketBase data lives in `/pb_data`. Stop writes and copy it into a new, empty host directory for each
filesystem-level backup:
```sh
pnpm docker:docs
docker compose -f apps/workshop/docker-compose.yml stop workshop
docker compose -f apps/workshop/docker-compose.yml cp workshop:/pb_data/. ./workshop-backup
docker compose -f apps/workshop/docker-compose.yml start workshop
```
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. PocketBase data persists in the
`homarr-workshop_pb_data` named volume.
Point a local Homarr checkout at it with:
Never copy a backup over a non-empty PocketBase volume: files created after the backup could survive and make the
restore inconsistent. For a development restore, first verify that the selected backup contains the database, then
empty the stopped volume before copying it:
```sh
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
test -f ./workshop-backup/data.db
docker compose -f apps/workshop/docker-compose.yml stop workshop
docker compose -f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint sh workshop \
-c 'find /pb_data -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
docker compose -f apps/workshop/docker-compose.yml cp ./workshop-backup/. workshop:/pb_data
docker compose -f apps/workshop/docker-compose.yml start workshop
curl --fail http://127.0.0.1:8090/api/health
curl --fail 'http://127.0.0.1:8090/api/collections/workshop_listings/records?perPage=1'
```
The production Homarr image reads the same variable when the container starts:
The cleanup command irreversibly empties the selected Compose volume, so confirm the backup path and preserve the
current volume separately before running it. After the HTTP checks, verify sign-in, reports, and a test installation.
Production backups must be versioned, access-controlled, restored into a clean staging volume, and validated before a
release.
## Production image and operations
The production target serves the built documentation from `/pb_public` and PocketBase from the same image. Required
production configuration:
```dotenv
HOMARR_WEBSITE_URL=https://homarr.dev
WORKSHOP_API_URL=https://homarr.dev
WORKSHOP_WEB_URL=https://homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
PB_ALLOWED_ORIGINS=*
```
Build and smoke-test the combined image locally:
```sh
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
docker compose -f apps/workshop/docker-compose.yml --profile docs up --build --detach docs
curl --fail http://127.0.0.1:3003/api/health
curl --fail http://127.0.0.1:3003/
curl --fail http://127.0.0.1:3003/workshop
docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs
```
This command starts the profiled `docs` service from `apps/workshop/docker-compose.yml`. It does not start the development `workshop` service. Stop and remove the preview with:
```sh
docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --force docs
```
Normal startup never deletes data. To intentionally recreate the local database, stop the stack first and then run:
Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional,
irreversible local reset:
```sh
docker compose -f apps/workshop/docker-compose.yml down --volumes
```
PocketBase data lives in `/pb_data`. Docker Compose persists it in the `pb_data` named volume for both the development Workshop service and the production documentation/Workshop service. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
At startup the container repairs ownership of existing data volumes, then runs PocketBase as its non-root user.
Never run the reset command against production. Release by immutable image digest, back up application and PocketBase
data first, rehearse restore and rollback in staging, and promote the exact tested digest. The Algolia recrawl workflow
remains manual until the external Homarr website host can dispatch it after promotion; publishing the image to GHCR does
not make that image live.
+6 -1
View File
@@ -10,9 +10,10 @@ services:
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${PB_EXPOSE_PORT:-8090}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:8090}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
volumes:
@@ -29,9 +30,13 @@ services:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
args:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003}
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${DOCS_EXPOSE_PORT:-3003}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
@@ -26,7 +26,7 @@
</tr>
<tr>
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
<p style="margin: 0 0 18px">A Workshop administrator removed your submission:</p>
<p style="margin: 0 0 18px">A Workshop moderator removed your submission:</p>
<div style="margin: 0 0 24px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2">
<strong>{{submissionTitle}}</strong>
</div>
+36 -46
View File
@@ -1,56 +1,46 @@
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
const MAX_CSS_LENGTH = 16_384;
const MAX_CONTENT_LENGTH = 1_000_000;
const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const rejectRequest = (message) => {
throw new BadRequestError(message);
};
const validateWidgetManifest = (content) => {
let widget;
try {
widget = JSON.parse(content);
} catch {
rejectRequest("Widget content must be valid JSON");
const normalizedIdentityText = (...values) => {
for (const value of values) {
if (typeof value !== "string") continue;
const normalized = [...value]
.filter((character) => {
const code = character.codePointAt(0) || 0;
return code > 31 && code !== 127;
})
.join("")
.trim();
if (normalized) return normalized;
}
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
rejectRequest("Widget name is invalid");
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
rejectRequest("Widget must define a default source");
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
for (const source of Object.values(widget.sources)) {
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
rejectRequest("Widget source URL is invalid");
if (!["public", "private", "loopback"].includes(source.networkScope))
rejectRequest("Widget source network scope is invalid");
}
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
rejectRequest("Widget requests are invalid");
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
rejectRequest("Widget options are invalid");
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
return JSON.stringify(widget);
return "";
};
const validateAndNormalizeSubmission = (record) => {
const type = record.getString("type");
const content = record.getString("content");
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
record.set("title", record.getString("title").trim());
record.set("description", record.getString("description").trim());
record.set("changelog", record.getString("changelog").trim());
if (type === "customCss") {
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
return;
}
if (type !== "customWidget") rejectRequest("Submission type is invalid");
record.set("content", validateWidgetManifest(content));
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
const deriveGithubIdentity = (oauthUser, recordId) => {
const rawUser = oauthUser && typeof oauthUser.rawUser === "object" ? oauthUser.rawUser : {};
const candidateUsername = normalizedIdentityText(oauthUser && oauthUser.username, rawUser.login);
const githubUsername =
GITHUB_USERNAME_PATTERN.test(candidateUsername) && !candidateUsername.includes("--") ? candidateUsername : "";
const displayName =
normalizedIdentityText(githubUsername, rawUser.login, oauthUser && oauthUser.name, rawUser.name).slice(0, 100) ||
`GitHub user ${String(recordId || "").slice(0, 8)}`;
const candidateAvatarUrl = normalizedIdentityText(
oauthUser && oauthUser.avatarURL,
oauthUser && oauthUser.avatarUrl,
rawUser.avatar_url,
);
const avatarUrl =
candidateAvatarUrl.length <= 2_048 && /^https:\/\/[^"'\\\s]+$/i.test(candidateAvatarUrl) ? candidateAvatarUrl : "";
return {
displayName,
avatarUrl,
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${githubUsername}` : "",
};
};
const escapeHtml = (value) =>
@@ -80,9 +70,9 @@ const sendEmail = (app, recipientEmail, subject, text, html) => {
};
module.exports = {
deriveGithubIdentity,
emailTemplate,
escapeHtml,
rejectRequest,
sendEmail,
validateAndNormalizeSubmission,
};
+84 -11
View File
@@ -1,19 +1,65 @@
/// <reference path="../pb_data/types.d.ts" />
onBootstrap((event) => {
event.next();
const users = event.app.findCollectionByNameOrId("users");
const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
if (Boolean(clientId) !== Boolean(clientSecret)) {
console.log(JSON.stringify({ event: "workshop_oauth_configuration_rejected", reason: "partial_credentials" }));
throw new Error("GitHub OAuth requires both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET");
}
const configured = Boolean(clientId && clientSecret);
event.next();
const users = event.app.findCollectionByNameOrId("users");
users.oauth2.enabled = configured;
users.oauth2.mappedFields = { id: "", name: "", username: "displayName", avatarURL: "avatarUrl" };
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
event.app.save(users);
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
});
onRecordAuthWithOAuth2Request((event) => {
if (event.providerName !== "github" || !event.oauth2User) {
event.next();
return;
}
const { deriveGithubIdentity } = require(`${__hooks}/workshop-utils.js`);
const identity = deriveGithubIdentity(event.oauth2User, event.record ? event.record.id : event.oauth2User.id);
if (event.record) {
event.record.set("displayName", identity.displayName);
event.record.set("avatar", "");
event.record.set("avatarUrl", identity.avatarUrl);
event.record.set("githubUsername", identity.githubUsername);
event.record.set("githubProfileUrl", identity.githubProfileUrl);
event.app.save(event.record);
} else {
event.createData.displayName = identity.displayName;
event.createData.avatarUrl = identity.avatarUrl;
event.createData.githubUsername = identity.githubUsername;
event.createData.githubProfileUrl = identity.githubProfileUrl;
}
console.log(
JSON.stringify({
event: "workshop_oauth_identity_synchronized",
provider: "github",
recordId: event.record ? event.record.id : null,
newRecord: !event.record,
}),
);
event.next();
}, "users");
onRecordUpdateRequest((event) => {
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
const originalAvatar = event.record.original().getString("avatar");
const pendingAvatarFiles = event.record.getUnsavedFiles("avatar");
if (event.record.getString("avatar") !== originalAvatar || pendingAvatarFiles.length > 0) {
rejectRequest("Workshop avatars are managed by GitHub OAuth");
}
event.next();
}, "users");
onRecordCreateRequest((event) => {
const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
validateAndNormalizeSubmission(event.record);
event.record.set("revision", 1);
event.record.set("expectedRevision", 0);
event.record.set("changelog", "");
@@ -22,20 +68,42 @@ onRecordCreateRequest((event) => {
}, "submissions");
onRecordUpdateRequest((event) => {
const { rejectRequest, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
const original = event.record.original();
const currentRevision = original.getInt("revision");
const expectedRevision = event.record.getInt("expectedRevision");
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
rejectRequest("Submission changed since it was read");
}
validateAndNormalizeSubmission(event.record);
event.record.set("expectedRevision", expectedRevision);
event.record.set("revision", currentRevision + 1);
event.next();
}, "submissions");
onRecordCreateRequest((event) => {
const auth = event.requestInfo().auth;
const reporter = event.record.getString("reporter");
const submission = event.record.getString("submission");
if (auth && auth.id === reporter) {
const dismissed = event.app.findRecordsByFilter(
"reports",
"reporter = {:reporter} && submission = {:submission} && status = 'dismissed'",
"",
1,
0,
{ reporter, submission },
);
if (dismissed.length > 0) {
event.app.delete(dismissed[0]);
console.log(
JSON.stringify({
event: "workshop_report_reopened",
reporterId: reporter,
submissionId: submission,
}),
);
}
}
event.record.set("status", "open");
event.next();
}, "reports");
@@ -62,6 +130,7 @@ onRecordAfterCreateSuccess((event) => {
const commenter = event.app.findRecordById("users", event.record.get("author"));
const recipientEmail = recipient.email();
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const publicWorkshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || `${publicOrigin}/workshop`).replace(/\/$/, "");
if (!recipientEmail || recipient.id === commenter.id || !publicOrigin) {
event.next();
@@ -71,7 +140,7 @@ onRecordAfterCreateSuccess((event) => {
const commenterName = commenter.getString("displayName") || "A community member";
const submissionTitle = submission.getString("title");
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
const template = emailTemplate("comment-email.html")
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
@@ -97,6 +166,7 @@ onRecordAfterCreateSuccess((event) => {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const publicWorkshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || `${publicOrigin}/workshop`).replace(/\/$/, "");
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
if (!publicOrigin || admins.length === 0) {
@@ -108,8 +178,8 @@ onRecordAfterCreateSuccess((event) => {
const submissionTitle = submission.getString("title");
const category = event.record.getString("category");
const explanation = event.record.getString("explanation");
const adminUrl = `${publicOrigin}/workshop/admin`;
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const adminUrl = `${publicWorkshopUrl}/admin`;
const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
const template = emailTemplate("report-email.html")
.replaceAll("{{reporterName}}", escapeHtml(reporterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
@@ -155,12 +225,15 @@ onRecordDeleteRequest((event) => {
if (!recipientEmail) return;
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const workshopUrl = publicOrigin ? `${publicOrigin}/workshop/` : "";
const workshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || (publicOrigin ? `${publicOrigin}/workshop` : "")).replace(
/\/?$/,
"/",
);
const template = emailTemplate("submission-deleted-email.html").replaceAll(
"{{submissionTitle}}",
escapeHtml(submissionTitle),
);
const text = `A Workshop administrator removed your submission “${submissionTitle}”.${
const text = `A Workshop moderator removed your submission “${submissionTitle}”.${
workshopUrl ? `\n\nReturn to Workshop: ${workshopUrl}` : ""
}`;
@@ -1,6 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const cloneRule = (value) => (value === null ? null : String(value));
const adminRule = "@request.auth.isAdmin = true";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
].join(" && ");
const immutableSubmissionFields = [
"@request.body.author:changed = false",
"@request.body.type:changed = false",
"@request.body.widgetSchema:changed = false",
].join(" && ");
const moderatorSubmissionFieldsUnchanged = [
"@request.body.title:changed = false",
"@request.body.description:changed = false",
"@request.body.content:changed = false",
"@request.body.changelog:changed = false",
"@request.body.screenshots:changed = false",
].join(" && ");
migrate(
(app) => {
@@ -13,7 +34,6 @@ migrate(
users = new Collection({ type: "auth", name: "users" });
}
const settings = app.settings();
const stateCollection = new Collection({
type: "base",
name: "workshop_migration_state",
@@ -31,15 +51,14 @@ migrate(
? {
passwordAuth: cloneJson(users.passwordAuth),
oauth2: cloneJson(users.oauth2),
listRule: users.listRule,
viewRule: users.viewRule,
createRule: users.createRule,
updateRule: users.updateRule,
deleteRule: users.deleteRule,
listRule: cloneRule(users.listRule),
viewRule: cloneRule(users.viewRule),
createRule: cloneRule(users.createRule),
updateRule: cloneRule(users.updateRule),
deleteRule: cloneRule(users.deleteRule),
}
: null,
addedUserFields: [],
rateLimits: cloneJson(settings.rateLimits),
};
const addUserField = (field) => {
if (users.fields.getByName(field.name)) return;
@@ -62,11 +81,10 @@ migrate(
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new BoolField({ name: "isAdmin" }));
const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
users.viewRule = "";
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
users.deleteRule = null;
app.save(users);
const stateRecord = new Record(stateCollection);
@@ -79,7 +97,9 @@ migrate(
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
updateRule:
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
`(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
@@ -182,7 +202,12 @@ migrate(
listRule: adminRule,
viewRule: adminRule,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
updateRule:
`${adminRule} && ` +
"@request.body.reporter:changed = false && " +
"@request.body.submission:changed = false && " +
"@request.body.category:changed = false && " +
"@request.body.explanation:changed = false",
deleteRule: adminRule,
fields: [
{
@@ -238,18 +263,6 @@ migrate(
`,
});
app.save(listings);
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
),
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
app.save(settings);
},
(app) => {
let stateCollection;
@@ -267,11 +280,6 @@ migrate(
} catch {}
}
const settings = app.settings();
settings.rateLimits.enabled = state.rateLimits.enabled;
settings.rateLimits.rules = state.rateLimits.rules;
app.save(settings);
const users = app.findCollectionByNameOrId("users");
if (state.usersExisted) {
users.passwordAuth = state.users.passwordAuth;
@@ -286,6 +294,8 @@ migrate(
if (field) users.fields.removeById(field.id);
}
app.save(users);
} else {
app.delete(users);
}
app.delete(stateCollection);
},
+1
View File
@@ -6,6 +6,7 @@ WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
node apps/workshop/tests/workshop-identity.mjs
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
@@ -23,5 +23,12 @@ const github = users.oauth2?.providers?.find((provider) => provider.name === "gi
if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
}
if (
users.oauth2?.mappedFields?.username !== "displayName" ||
users.oauth2?.mappedFields?.name ||
users.oauth2?.mappedFields?.avatarURL !== "avatarUrl"
) {
throw new Error(`GitHub OAuth field mapping was not synchronized: ${JSON.stringify(users.oauth2?.mappedFields)}`);
}
console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
+20 -14
View File
@@ -9,25 +9,30 @@ if (typeof clientExport !== "string") throw new Error("Workshop client export is
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
const hookUtils = await read("apps/workshop/pb_hooks/workshop-utils.js");
const schema = await read("packages/workshop/src/schema.ts");
const constantValue = (source, name) => {
const match = source.match(new RegExp(`(?:export )?const ${name} = ([\\d_]+)`));
if (!match?.[1]) throw new Error(`Workshop limit ${name} is missing or is not a numeric literal`);
return Number(match[1].replaceAll("_", ""));
};
if (constantValue(hookUtils, "MAX_CSS_LENGTH") !== constantValue(schema, "MAX_WORKSHOP_CSS_LENGTH")) {
throw new Error("PocketBase and shared Workshop CSS limits must match");
}
if (constantValue(hookUtils, "MAX_CONTENT_LENGTH") !== constantValue(schema, "MAX_WORKSHOP_CONTENT_LENGTH")) {
throw new Error("PocketBase and shared Workshop content limits must match");
}
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
}
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (hook.includes("validateAndNormalizeSubmission")) {
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
}
for (const artifact of [
"apps/workshop/pb_hooks/widget-validator.js",
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
"packages/workshop/src/pocketbase-validator.ts",
"scripts/build-workshop-validator.mjs",
]) {
await access(resolve(artifact))
.then(() => {
throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
})
.catch((error) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
throw error;
});
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
@@ -36,11 +41,12 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi
for (const required of [
"workshop_migration_state",
"addedUserFields",
"cloneRule(users.listRule)",
"CREATE TRIGGER submissions_revision_cas",
"NEW.expectedRevision != OLD.revision",
"state.rateLimits.enabled",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
"app.delete(users)",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
+63
View File
@@ -0,0 +1,63 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { deriveGithubIdentity } = require("../pb_hooks/workshop-utils.js");
const identity = deriveGithubIdentity(
{
name: " Octo Cat ",
username: "octocat",
avatarURL: "https://avatars.githubusercontent.com/u/1?v=4",
rawUser: {},
},
"record123456",
);
if (
identity.displayName !== "octocat" ||
identity.githubUsername !== "octocat" ||
identity.githubProfileUrl !== "https://github.com/octocat" ||
identity.avatarUrl !== "https://avatars.githubusercontent.com/u/1?v=4"
) {
throw new Error(`Trusted GitHub identity was not normalized: ${JSON.stringify(identity)}`);
}
const rawFallback = deriveGithubIdentity(
{
name: "",
username: "",
avatarURL: "",
rawUser: {
name: "\nRaw User\u0000",
login: "raw-user",
avatar_url: "https://avatars.githubusercontent.com/u/2",
},
},
"record123456",
);
if (
rawFallback.displayName !== "raw-user" ||
rawFallback.githubUsername !== "raw-user" ||
rawFallback.githubProfileUrl !== "https://github.com/raw-user"
) {
throw new Error(`Raw GitHub identity fallback was not normalized: ${JSON.stringify(rawFallback)}`);
}
const rejected = deriveGithubIdentity(
{
name: "\n",
username: "not/a/github-user",
avatarURL: "javascript:alert(1)",
rawUser: {},
},
"record123456",
);
if (
rejected.displayName !== "GitHub user record12" ||
rejected.githubUsername !== "" ||
rejected.githubProfileUrl !== "" ||
rejected.avatarUrl !== ""
) {
throw new Error(`Invalid provider identity fields were retained: ${JSON.stringify(rejected)}`);
}
console.log("Workshop OAuth identity normalization passed");
+12 -19
View File
@@ -50,7 +50,7 @@ class Record {
const original = {
passwordAuth: { enabled: true, identityFields: ["email"] },
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before", clientSecret: "before-secret" }] },
rules: {
listRule: "original-list",
viewRule: "original-view",
@@ -58,13 +58,11 @@ const original = {
updateRule: "original-update",
deleteRule: "original-delete",
},
rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
};
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
users.passwordAuth = structuredClone(original.passwordAuth);
users.oauth2 = structuredClone(original.oauth2);
Object.assign(users, original.rules);
const settings = { rateLimits: structuredClone(original.rateLimits) };
const collections = new Map([[users.id, users]]);
const records = new Map();
const rawQueries = [];
@@ -99,9 +97,6 @@ const app = {
collections.delete(collection.id);
records.delete(collection.id);
},
settings() {
return settings;
},
};
let migration;
@@ -122,7 +117,7 @@ vm.runInNewContext(source, sandbox);
if (!migration) throw new Error("Workshop migration did not register");
migration.up(app);
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
if (!collections.has("workshop_migration_state")) {
throw new Error("Workshop migration did not capture state before applying changes");
}
if (
@@ -145,8 +140,6 @@ for (const [name, value] of Object.entries(original.rules)) {
}
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
throw new Error("Workshop user fields were not removed on rollback");
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
throw new Error("Rate limits were not restored");
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
}
@@ -154,20 +147,20 @@ for (const name of ["workshop_migration_state", "workshop_listings", "reports",
collections.clear();
records.clear();
rawQueries.length = 0;
settings.rateLimits = structuredClone(original.rateLimits);
migration.up(app);
const bootstrappedUsers = collections.get("users");
if (!bootstrappedUsers) throw new Error("Fresh migration did not create the users collection");
if (!collections.has("users")) throw new Error("Fresh migration did not create the users collection");
migration.down(app);
if (collections.get("users") !== bootstrappedUsers) {
throw new Error("Fresh rollback removed the users collection required by the bootstrap hook");
}
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
for (const name of [
"users",
"workshop_migration_state",
"workshop_listings",
"reports",
"comments",
"votes",
"submissions",
]) {
if (collections.has(name)) throw new Error(`Fresh rollback left collection ${name}`);
}
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits)) {
throw new Error("Fresh rollback did not restore rate limits");
}
console.log("Workshop migration up/down restoration passed");
+106 -35
View File
@@ -77,6 +77,55 @@ await expectStatus(
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
404,
);
for (const [field, value] of [
["displayName", "Homarr Team"],
["avatarUrl", "https://example.invalid/forged-avatar.png"],
["githubUsername", "homarr-labs"],
["githubProfileUrl", "https://github.com/homarr-labs"],
]) {
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ [field]: value }) },
404,
);
}
const forgedAvatar = new FormData();
forgedAvatar.set(
"avatar",
new Blob(
[
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
],
{ type: "image/png" },
),
"forged-avatar.png",
);
const avatarResponse = await fetch(`${baseUrl}/api/collections/users/records/${author.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: forgedAvatar,
});
const avatarResponseBody = await avatarResponse.json().catch(() => null);
if (avatarResponse.status !== 400 || !JSON.stringify(avatarResponseBody).includes("managed by GitHub OAuth")) {
throw new Error(
`Workshop user avatar upload bypassed the OAuth identity hook: ${avatarResponse.status} ${JSON.stringify(avatarResponseBody)}`,
);
}
const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
headers: authorSession.headers,
});
if (
unchangedAuthor.displayName !== "Widget Author" ||
unchangedAuthor.avatar ||
unchangedAuthor.avatarUrl ||
unchangedAuthor.githubUsername ||
unchangedAuthor.githubProfileUrl
) {
throw new Error("Workshop users can forge OAuth provider identity fields");
}
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
@@ -105,7 +154,7 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: "homarr-custom-css-v1",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
revision: 999,
@@ -121,38 +170,8 @@ if (
submission.changelog !== "" ||
submission.outdated !== false
) {
throw new Error("Submission publication was not validated and normalized server-side");
throw new Error("Submission publication metadata was not initialized server-side");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customCss",
title: "Oversized stylesheet",
content: "x".repeat(16_385),
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Invalid widget payload",
content: ".dashboard { color: red; }",
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
@@ -295,7 +314,7 @@ const report = await request("/api/collections/reports/records", {
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop administrators");
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop moderators");
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
@@ -317,14 +336,45 @@ await expectStatus(
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
throw new Error("Workshop administrators must be able to review reports");
throw new Error("Workshop moderators must be able to review reports");
}
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ category: "spam", explanation: "Moderator-authored replacement" }),
},
404,
);
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ status: "dismissed" }),
});
if (dismissedReport.status !== "dismissed") throw new Error("Workshop administrators must be able to dismiss reports");
if (dismissedReport.status !== "dismissed") throw new Error("Workshop moderators must be able to dismiss reports");
const reopenedReport = await request("/api/collections/reports/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
submission: submission.id,
reporter: visitor.id,
category: "malicious",
explanation: "The later submission revision now needs another review",
status: "dismissed",
}),
});
if (reopenedReport.status !== "open" || reopenedReport.id === report.id) {
throw new Error("A dismissed Workshop report must be replaceable with a fresh open report");
}
const reopenedReports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (
reopenedReports.items.length !== 1 ||
reopenedReports.items[0].id !== reopenedReport.id ||
reopenedReports.items[0].status !== "open"
) {
throw new Error("Re-reporting must replace only the reporter's dismissed report");
}
const visitorSubmission = await request("/api/collections/submissions/records", {
method: "POST",
@@ -340,6 +390,27 @@ const visitorSubmission = await request("/api/collections/submissions/records",
outdated: false,
}),
});
await expectStatus(
`/api/collections/submissions/records/${visitorSubmission.id}`,
{
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({
title: "Moderator-authored replacement",
content: JSON.stringify({ ...widget, name: "Moderator-authored replacement" }),
expectedRevision: 1,
}),
},
404,
);
const moderatedStatus = await request(`/api/collections/submissions/records/${visitorSubmission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, expectedRevision: 1 }),
});
if (!moderatedStatus.outdated || moderatedStatus.revision !== 2) {
throw new Error("Workshop moderators must be limited to CAS-protected submission status changes");
}
await expectStatus(
`/api/collections/submissions/records/${visitorSubmission.id}`,
{ method: "DELETE", headers: authorSession.headers },
@@ -4,3 +4,4 @@ export {
parseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
+18
View File
@@ -1,16 +1,34 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { normalizeHttpUrl } from "@homarr/workshop/schema";
const publicHttpUrl = (variableName: string) =>
z.string().transform((value, context) => {
try {
return normalizeHttpUrl(value, variableName);
} catch (error) {
context.addIssue({
code: "custom",
message: error instanceof Error ? error.message : `${variableName} is invalid`,
});
return z.NEVER;
}
});
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
},
});
+15
View File
@@ -61,6 +61,10 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
export const boardRouter = createTRPCRouter({
@@ -372,6 +376,7 @@ export const boardRouter = createTRPCRouter({
}
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
const newBoardId = createId();
@@ -855,6 +860,11 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -1440,6 +1450,11 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
storedItems: [],
});
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
@@ -0,0 +1,85 @@
import { isDeepStrictEqual } from "node:util";
import { TRPCError } from "@trpc/server";
interface BoardPlacement {
id: string;
kind: string;
options: Record<string, unknown>;
}
interface CustomWidgetPlacementGuardInput {
isAdmin: boolean;
submittedItems: readonly BoardPlacement[];
storedItems: readonly BoardPlacement[];
}
const throwAdminRequired = () => {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only administrators can add or configure Custom Widgets",
});
};
const assertAuthoringAllowed = (isAdmin: boolean) => {
if (!isAdmin) throwAdminRequired();
};
/**
* Protects the security-sensitive part of Custom Widget placements while
* leaving ordinary board operations (layout, presentation and removal) to the
* existing board permission checks.
*/
export const throwIfCustomWidgetPlacementChangeForbidden = ({
isAdmin,
submittedItems,
storedItems,
}: CustomWidgetPlacementGuardInput) => {
const storedById = new Map(storedItems.map((item) => [item.id, item]));
for (const submitted of submittedItems) {
const stored = storedById.get(submitted.id);
if (!stored) {
if (submitted.kind === "customApi") assertAuthoringAllowed(isAdmin);
continue;
}
const wasCustomWidget = stored.kind === "customApi";
const isCustomWidget = submitted.kind === "customApi";
if (wasCustomWidget !== isCustomWidget) {
if (isCustomWidget) assertAuthoringAllowed(isAdmin);
else if (!isAdmin) throwAdminRequired();
continue;
}
if (
wasCustomWidget &&
!isDeepStrictEqual(normalizeCustomWidgetOptions(submitted.options), normalizeCustomWidgetOptions(stored.options))
) {
assertAuthoringAllowed(isAdmin);
}
}
};
export const throwIfCustomWidgetBoardDuplicationForbidden = (
isAdmin: boolean,
items: readonly Pick<BoardPlacement, "kind">[],
) => {
if (items.some((item) => item.kind === "customApi")) assertAuthoringAllowed(isAdmin);
};
function normalizeCustomWidgetOptions(options: Record<string, unknown>) {
return {
...options,
configuration:
options.configuration !== null &&
typeof options.configuration === "object" &&
!Array.isArray(options.configuration)
? options.configuration
: {},
configurationVersion:
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
? options.configurationVersion
: 1,
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : 30,
};
}
@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "../custom-widget-placement-access";
const placement = {
id: "widget-1",
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
},
};
describe("Custom Widget placement access", () => {
test("non-admins cannot add a Custom Widget placement", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [{ ...placement, options: { ...placement.options, configurationVersion: 1 } }],
storedItems: [],
}),
).toThrowError("Only administrators can add or configure Custom Widgets");
});
test.each([
["definition", { ...placement, options: { ...placement.options, definitionId: "definition-2" } }],
["refresh interval", { ...placement, options: { ...placement.options, refreshInterval: 60 } }],
[
"option configuration",
{ ...placement, options: { ...placement.options, configuration: { server: "secondary" } } },
],
["widget kind", { ...placement, kind: "weather" }],
])("non-admins cannot change an existing Custom Widget %s", (_label, submitted) => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [submitted],
storedItems: [placement],
}),
).toThrowError("Only administrators can add or configure Custom Widgets");
});
test("non-admins may move, resize and change presentation for an existing placement", () => {
const submitted = {
...placement,
layouts: [{ xOffset: 2, yOffset: 4, width: 6, height: 3 }],
advancedOptions: { title: "Status", customCssClasses: ["wide"], borderColor: "#fff" },
};
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [submitted],
storedItems: [placement],
}),
).not.toThrow();
});
test("non-admins may remove an existing placement", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [],
storedItems: [placement],
}),
).not.toThrow();
});
test("admins may create and reconfigure placements", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: true,
submittedItems: [{ ...placement, options: { definitionId: "definition-2" } }],
storedItems: [],
}),
).not.toThrow();
});
test("duplicating a board with Custom Widgets requires admin", () => {
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [placement])).toThrowError(
"Only administrators can add or configure Custom Widgets",
);
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, [placement])).not.toThrow();
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [{ kind: "weather" }])).not.toThrow();
});
test("treats missing persisted v2 defaults as equivalent during presentation-only edits", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [placement],
storedItems: [
{
id: placement.id,
kind: placement.kind,
options: {
definitionId: placement.options.definitionId,
refreshInterval: placement.options.refreshInterval,
configuration: placement.options.configuration,
},
},
],
}),
).not.toThrow();
});
});
@@ -30,24 +30,18 @@ import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const customWidgetRouter = createTRPCRouter({
...metadataProcedures,
...managementQueryProcedures,
create: manageProcedure
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
if (secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing custom widget secrets requires dedicated permission",
});
}
const definition = customWidgetDefinitionSchema.parse(candidate);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
@@ -55,7 +49,8 @@ export const customWidgetRouter = createTRPCRouter({
return { id };
}),
update: manageProcedure
update: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
@@ -66,12 +61,6 @@ export const customWidgetRouter = createTRPCRouter({
const current = parseStoredCustomWidgetDefinition(existing);
const { id, secrets, ...changes } = input;
if (secrets?.length && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing custom widget secrets requires dedicated permission",
});
}
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
@@ -194,7 +183,8 @@ export const customWidgetRouter = createTRPCRouter({
...secretProcedures,
...workshopProcedures,
toggleEnabled: manageProcedure
toggleEnabled: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (input.enabled) {
@@ -216,7 +206,8 @@ export const customWidgetRouter = createTRPCRouter({
.where(eq(customWidgetDefinitions.id, input.id));
}),
delete: manageProcedure
delete: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
@@ -224,20 +215,23 @@ export const customWidgetRouter = createTRPCRouter({
logger.info("Deleted custom widget definition", { id: input.id });
}),
duplicate: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
creatorId: ctx.session.user.id,
});
return { id, name: `${current.name} (copy)` };
}),
duplicate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
creatorId: ctx.session.user.id,
});
return { id, name: `${current.name} (copy)` };
}),
...templateProcedures,
...transferProcedures,
@@ -1,5 +1,3 @@
import { parse as parseSuperJson } from "superjson";
import type { legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
@@ -13,14 +11,17 @@ export function buildLegacyCustomWidgetMigrationPrompt(
$schema: "homarr-custom-widget-v1",
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
iconUrl: definition.iconUrl ? redactLegacyUrl(definition.iconUrl) : null,
url: redactLegacyUrl(definition.url),
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: redactLegacyRequestBody(definition.requestBody),
displayType: definition.displayType,
displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
displayConfig: {
migrationNote:
"The preserved v1 display configuration was intentionally omitted because legacy free-text fields may contain credentials. Reconstruct it from the display type and visible behavior.",
},
configuredSecretKinds,
};
return buildCustomWidgetAiPrompt(
@@ -69,11 +70,3 @@ function redactAllValues(value: unknown): unknown {
if (value === null) return null;
return "[REDACTED VALUE]";
}
function parseLegacyDisplayConfig(value: string): unknown {
try {
return parseSuperJson(value) as unknown;
} catch {
return { migrationNote: "The stored v1 display configuration could not be decoded.", raw: value.slice(0, 4_000) };
}
}
@@ -6,7 +6,7 @@ import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { permissionRequiredProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
@@ -26,10 +26,9 @@ import {
mapLegacyCustomWidgetListItem,
} from "./management-query-mappers";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const managementQueryProcedures = {
list: manageProcedure
list: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
const [definitions, legacyDefinitions] = await Promise.all([
@@ -43,11 +42,13 @@ export const managementQueryProcedures = {
}),
]);
const current = definitions.map(mapCustomWidgetListItem);
const legacy = legacyDefinitions.map(mapLegacyCustomWidgetListItem);
const currentIds = new Set(definitions.map(({ id }) => id));
const legacy = legacyDefinitions.filter(({ id }) => !currentIds.has(id)).map(mapLegacyCustomWidgetListItem);
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
}),
legacyMigrationPrompt: manageProcedure
legacyMigrationPrompt: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -72,7 +73,8 @@ export const managementQueryProcedures = {
};
}),
get: manageProcedure
get: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
@@ -96,7 +98,8 @@ export const managementQueryProcedures = {
};
}),
available: protectedProcedure
available: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
@@ -115,7 +118,8 @@ export const managementQueryProcedures = {
return [...available, mapLegacyAvailableCustomWidget(legacy)];
}),
optionRequest: protectedProcedure
optionRequest: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
boardId: z.string(),
@@ -20,6 +20,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
event: "custom_widget_definition_invalid",
id: definition.id,
issueCount: result.issues.length,
});
@@ -88,7 +89,11 @@ export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
export function mapAvailableCustomWidget(definition: StoredDefinition) {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
logger.warn("Excluded invalid custom widget definition from board picker", {
event: "custom_widget_definition_invalid",
id: definition.id,
surface: "board-picker",
});
return [];
}
@@ -4,15 +4,16 @@ import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/autho
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
import { permissionRequiredProcedure } from "../../trpc";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const metadataProcedures = {
schema: publicProcedure
schema: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
.query(() => getCustomWidgetJsonSchema()),
getAuthoringPrompt: publicProcedure
getAuthoringPrompt: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
.query(() => ({
version: 2,
@@ -25,11 +26,13 @@ export const metadataProcedures = {
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
getSkill: publicProcedure
getSkill: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.query(() => getCustomWidgetSkill()),
validate: manageProcedure
validate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
@@ -12,10 +12,9 @@ import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValue
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession } from "./preview-sessions";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const previewActionProcedures = {
previewAction: manageProcedure
previewAction: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
})
@@ -17,8 +17,6 @@ import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
@@ -26,13 +24,11 @@ const previewCreateInputSchema = z.object({
options: z.record(z.string(), z.unknown()).optional(),
});
const previewCreateProcedure = manageProcedure
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
.input(previewCreateInputSchema)
.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 preview secrets requires dedicated permission" });
}
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
if (optionIssues.length > 0) {
@@ -62,7 +58,7 @@ const previewCreateProcedure = manageProcedure
);
if (missingReplacement) {
throw new TRPCError({
code: ctx.session.user.permissions.includes("custom-widget-secret-write") ? "BAD_REQUEST" : "FORBIDDEN",
code: "BAD_REQUEST",
message: "Source security settings changed; re-enter its credentials to preview this definition",
});
}
@@ -123,25 +119,28 @@ const previewCreateProcedure = manageProcedure
export const previewBaseProcedures = {
previewCreate: previewCreateProcedure,
previewGet: manageProcedure.input(z.object({ sessionId: z.string() })).query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
return {
id: session.id,
name: session.name,
expiresAt: session.expiresAt,
template: session.template,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: Object.entries(session.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.permission,
trigger: request.trigger,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
})),
liveActions: session.liveActions,
};
}),
previewGet: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
return {
id: session.id,
name: session.name,
expiresAt: session.expiresAt,
template: session.template,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: Object.entries(session.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.permission,
trigger: request.trigger,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
})),
liveActions: session.liveActions,
};
}),
};
@@ -13,10 +13,9 @@ import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./req
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const previewQueryProcedures = {
previewQuery: manageProcedure
previewQuery: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -76,13 +75,15 @@ export const previewQueryProcedures = {
}
}),
setPreviewLiveActions: manageProcedure
setPreviewLiveActions: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) =>
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
),
previewJournal: manageProcedure
previewJournal: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -0,0 +1,289 @@
import { TRPCError } from "@trpc/server";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import type { CustomWidgetCreateInput, CustomWidgetSecretKind, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
import { assertSecretSources, hasSameSecretBinding } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
type ConfigureCustomWidgetSourceInput = {
definitionId: string;
sourceId: string;
baseUrl: string;
networkScope?: CustomWidgetSource["networkScope"];
secrets: CustomWidgetCreateInput["secrets"];
expectedSource?: CustomWidgetSource;
};
type SourceConfigurationIssue = "definition-not-found" | "source-not-found" | "binding-changed";
class SourceConfigurationError extends Error {
constructor(readonly issue: SourceConfigurationIssue) {
super(`Custom widget source configuration failed: ${issue}`);
this.name = "SourceConfigurationError";
}
}
export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) {
const updatedAt = new Date();
const secretRow = {
definitionId,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
};
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, definitionId),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secretRow);
await transaction
.update(schema.customWidgetDefinitions)
.set({ updatedAt })
.where(eq(schema.customWidgetDefinitions.id, definitionId));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, definitionId),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secretRow).run();
transaction
.update(customWidgetDefinitions)
.set({ updatedAt })
.where(eq(customWidgetDefinitions.id, definitionId))
.run();
});
},
});
}
export async function configureCustomWidgetSource(db: Database, input: ConfigureCustomWidgetSourceInput) {
try {
return await persistCustomWidgetSourceConfiguration(db, input);
} catch (error) {
if (!(error instanceof SourceConfigurationError)) throw error;
if (error.issue === "definition-not-found") throw new TRPCError({ code: "NOT_FOUND" });
if (error.issue === "source-not-found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
}
throw new TRPCError({
code: "CONFLICT",
message: "Widget source authentication changed while it was being configured",
});
}
}
export async function configureCustomWidgetSourceFromRequest(
db: Database,
input: ConfigureCustomWidgetSourceInput & { expectedSource: CustomWidgetSource },
) {
try {
return {
status: "configured" as const,
source: await persistCustomWidgetSourceConfiguration(db, input),
};
} catch (error) {
if (!(error instanceof SourceConfigurationError)) throw error;
return { status: error.issue } as const;
}
}
async function persistCustomWidgetSourceConfiguration(db: Database, input: ConfigureCustomWidgetSourceInput) {
let configuredSource: CustomWidgetSource | undefined;
const secretRows = input.secrets.map((secret) => ({
definitionId: input.definitionId,
sourceId: input.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
const [stored] = await transaction
.select()
.from(schema.customWidgetDefinitions)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId))
.limit(1)
.for("update");
const sourceUpdate = prepareSourceUpdate(stored, input);
configuredSource = sourceUpdate.source;
await transaction
.update(schema.customWidgetDefinitions)
.set(sourceUpdate.definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
if (sourceUpdate.bindingChanged) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
),
);
}
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
.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(database) {
database.transaction(
(transaction) => {
const stored = transaction
.select()
.from(customWidgetDefinitions)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.limit(1)
.get();
const sourceUpdate = prepareSourceUpdate(stored, input);
configuredSource = sourceUpdate.source;
transaction
.update(customWidgetDefinitions)
.set(sourceUpdate.definitionChanges)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
if (sourceUpdate.bindingChanged) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
),
)
.run();
}
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
},
{ behavior: "immediate" },
);
},
});
if (!configuredSource) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return configuredSource;
}
function prepareSourceUpdate(
stored: Parameters<typeof parseStoredCustomWidgetDefinition>[0] | undefined,
input: ConfigureCustomWidgetSourceInput,
) {
if (!stored) throw new SourceConfigurationError("definition-not-found");
const definition = parseStoredCustomWidgetDefinition(stored);
const previousSource = definition.sources[input.sourceId];
if (!previousSource) throw new SourceConfigurationError("source-not-found");
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
throw new SourceConfigurationError("binding-changed");
}
const source = customWidgetSourceSchema.parse({
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
return {
source,
bindingChanged: !hasSameSecretBinding(previousSource, source),
definitionChanges: {
...serializeCustomWidgetDefinition({
...definition,
sources: { ...definition.sources, [input.sourceId]: source },
}),
updatedAt: new Date(),
},
};
}
export async function clearCustomWidgetSecret(
db: Database,
input: {
definitionId: string;
sourceId: string;
kind: CustomWidgetSecretKind;
},
) {
const updatedAt = new Date();
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
eq(schema.customWidgetSecrets.kind, input.kind),
),
);
await transaction
.update(schema.customWidgetDefinitions)
.set({ updatedAt })
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, input.kind),
),
)
.run();
transaction
.update(customWidgetDefinitions)
.set({ updatedAt })
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
});
},
});
}
@@ -1,9 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetSecretInputSchema,
customWidgetSecretsInputSchema,
@@ -16,12 +15,10 @@ import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { clearCustomWidgetSecret, configureCustomWidgetSource, setCustomWidgetSecret } from "./secret-persistence";
import { getPreviewSession } from "./preview-sessions";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const secretWriteProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-secret-write");
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const secretRequestInputSchema = z
.object({
@@ -44,7 +41,8 @@ const secretRequestInputSchema = z
});
export const secretProcedures = {
secretSet: secretWriteProcedure
secretSet: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Set one encrypted secret for a custom widget source." } })
.input(z.object({ definitionId: z.string(), secret: customWidgetSecretInputSchema }))
.mutation(async ({ ctx, input }) => {
@@ -54,30 +52,12 @@ export const secretProcedures = {
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(existing);
assertSecretSources(definition.sources, [input.secret]);
await ctx.db
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.secret.sourceId),
eq(customWidgetSecrets.kind, input.secret.kind),
),
);
await ctx.db.insert(customWidgetSecrets).values({
definitionId: input.definitionId,
sourceId: input.secret.sourceId,
kind: input.secret.kind,
encryptedValue: encryptSecret(input.secret.value),
updatedAt: new Date(),
});
await ctx.db
.update(customWidgetDefinitions)
.set({ updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.definitionId));
await setCustomWidgetSecret(ctx.db, input.definitionId, input.secret);
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
}),
sourceConfigure: manageProcedure
sourceConfigure: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
@@ -89,9 +69,6 @@ export const secretProcedures = {
}),
)
.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),
});
@@ -105,91 +82,24 @@ export const secretProcedures = {
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 bindingChanged = !hasSameSecretBinding(current, source);
const secretRows = input.secrets.map((secret) => ({
const configuredSource = await configureCustomWidgetSource(ctx.db, {
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));
if (bindingChanged) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
),
);
}
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
.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();
if (bindingChanged) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
),
)
.run();
}
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
baseUrl: source.baseUrl,
networkScope: input.networkScope,
secrets: input.secrets,
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: source.baseUrl,
networkScope: source.networkScope,
baseUrl: configuredSource.baseUrl,
networkScope: configuredSource.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
};
}),
configurationRequestUser: manageProcedure
configurationRequestUser: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
.input(secretRequestInputSchema)
.mutation(async ({ ctx, input }) => {
@@ -203,12 +113,6 @@ export const secretProcedures = {
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
if (!ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Creating a stored credential setup link requires dedicated permission",
});
}
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
@@ -258,7 +162,8 @@ export const secretProcedures = {
};
}),
secretClear: secretWriteProcedure
secretClear: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
)
@@ -267,19 +172,7 @@ export const secretProcedures = {
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.db
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, input.kind),
),
);
await ctx.db
.update(customWidgetDefinitions)
.set({ updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.definitionId));
await clearCustomWidgetSecret(ctx.db, input);
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
}),
};
@@ -11,7 +11,6 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
@@ -33,22 +32,26 @@ const validateTemplate = (definition: ReturnType<typeof parseStoredCustomWidgetD
};
export const templateProcedures = {
readTemplate: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const template = parseStoredCustomWidgetDefinition(definition).template;
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
readTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const template = parseStoredCustomWidgetDefinition(definition).template;
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
writeTemplate: manageProcedure
writeTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
@@ -74,7 +77,8 @@ export const templateProcedures = {
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
}),
templatePatch: manageProcedure
templatePatch: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
.input(
z.object({
@@ -7,39 +7,37 @@ import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { permissionRequiredProcedure } from "../../trpc";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
export: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return parseStoredCustomWidgetDefinition(definition);
}),
export: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return parseStoredCustomWidgetDefinition(definition);
}),
import: manageProcedure
import: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing imported widget secrets requires dedicated permission",
});
}
assertSecretSources(input.widget.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
migrateLegacy: manageProcedure
migrateLegacy: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -55,12 +53,6 @@ export const transferProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing migrated widget secrets requires dedicated permission",
});
}
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
@@ -108,41 +100,21 @@ export const transferProcedures = {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
await transaction
.delete(schema.legacyCustomWidgetDefinitions)
.where(eq(schema.legacyCustomWidgetDefinitions.id, legacy.id));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
transaction
.delete(legacyCustomWidgetDefinitions)
.where(eq(legacyCustomWidgetDefinitions.id, legacy.id))
.run();
});
},
});
logger.info("Migrated legacy custom widget definition", {
logger.info("Created v2 replacement for preserved legacy custom widget definition", {
id: legacy.id,
preservedSecretCount: preservedSecrets.length,
});
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
}),
deleteLegacy: manageProcedure
.meta({
mcp: {
enabled: true,
description: "Permanently delete one preserved legacy Custom Widget that will not be migrated.",
},
})
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(legacyCustomWidgetDefinitions).where(eq(legacyCustomWidgetDefinitions.id, input.id));
logger.info("Deleted legacy custom widget definition", { id: input.id });
}),
};
function canPreserveLegacySecrets(
@@ -10,15 +10,35 @@ import {
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
} from "@homarr/custom-widgets/core";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { validateWorkshopWidget, WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { resolveHomarrUrlConfig, validateWorkshopWidget } from "@homarr/workshop/schema";
import { env } from "../../env";
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);
const logger = createLogger({ module: "custom-widget:workshop" });
const workshopUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
});
const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
function throwWorkshopUnavailable(
message: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
): never {
logger.error(message, {
event,
errorName: "WorkshopBackendError",
});
throw new TRPCError({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
}
async function getWorkshopWidget(submissionId: string) {
try {
@@ -36,11 +56,7 @@ async function getWorkshopWidget(submissionId: string) {
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,
});
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
}
}
@@ -59,7 +75,8 @@ const sourceOverridesSchema = z.record(
);
export const workshopProcedures = {
workshopSearch: manageProcedure
workshopSearch: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
.input(workshopSearchInputSchema)
.query(async ({ input }) => {
@@ -86,16 +103,13 @@ export const workshopProcedures = {
reportCount: item.reportCount,
})),
};
} catch (error) {
throw new TRPCError({
code: "BAD_GATEWAY",
message: error instanceof Error ? error.message : "Workshop is unavailable",
cause: error,
});
} catch {
throwWorkshopUnavailable("Workshop widget search failed", "workshop_widget_search_failed");
}
}),
workshopGet: manageProcedure
workshopGet: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
.input(z.object({ submissionId: z.string().min(1) }))
.query(async ({ input }) => {
@@ -119,7 +133,8 @@ export const workshopProcedures = {
};
}),
workshopInstall: manageProcedure
workshopInstall: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
.input(
z.object({
@@ -130,12 +145,6 @@ export const workshopProcedures = {
}),
)
.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) {
@@ -1483,6 +1483,63 @@ describe("saveLayouts should save layout changes", () => {
});
});
describe("Custom Widget placement permissions", () => {
test("rejects a direct addItem request from a non-admin board modifier", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "custom-widget-add");
await expect(
caller.addItem({
boardId,
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("rejects a direct saveBoard request that adds a Custom Widget for a non-admin", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "custom-widget-save");
await expect(
caller.saveBoard({
id: boardId,
sections: [{ id: sectionId, kind: "empty", yOffset: 0, xOffset: 0 }],
items: [
{
id: createId(),
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
layouts: [
{
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
},
],
},
],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
@@ -9,24 +9,26 @@ import {
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } 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";
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
const userId = createId();
const session = {
user: {
id: userId,
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const manageOnlySession = {
const nonAdminSession = {
...session,
user: { ...session.user, permissions: ["custom-widget-manage"] },
user: { ...session.user, permissions: ["board-modify-all"] },
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
@@ -38,11 +40,11 @@ function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
function createManageOnlyCaller(db: ReturnType<typeof createDb>) {
function createNonAdminCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({
db,
deviceType: undefined,
session: manageOnlySession,
session: nonAdminSession,
});
}
@@ -54,14 +56,24 @@ async function prepareDatabase() {
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
BEFORE INSERT ON custom_widget_secret
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_secret_insert
BEFORE INSERT ON custom_widget_v2_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
function rejectDefinitionUpdates(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
BEFORE UPDATE ON custom_widget_v2_definition
BEGIN
SELECT RAISE(ABORT, 'forced definition update failure');
END`),
);
}
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const id = "legacy-jellyfin";
const encryptedValue = "preserved.secret" as `${string}.${string}`;
@@ -88,13 +100,21 @@ async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("atomically replaces a confirmed legacy widget while preserving its stable ID and credential", async () => {
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
expect(await db.query.legacyCustomWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
name: "Legacy Jellyfin",
enabled: false,
});
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
enabled: false,
@@ -106,6 +126,20 @@ describe("custom widget definition persistence", () => {
kind: "apiKey",
encryptedValue: legacy.encryptedValue,
});
const migratedList = await createCaller(db).list();
expect(migratedList).toHaveLength(1);
expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
await createCaller(db).delete({ id: legacy.id });
const fallbackList = await createCaller(db).list();
expect(fallbackList).toHaveLength(1);
expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
});
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
@@ -149,6 +183,30 @@ describe("custom widget definition persistence", () => {
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("refuses to export a stored definition containing embedded credentials", async () => {
const db = await prepareDatabase();
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const counts = definition.requests.counts;
if (!counts) throw new Error("Jellyfin counts request is missing");
const id = createId();
await db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({
...definition,
requests: {
...definition.requests,
counts: {
...counts,
headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
},
},
}),
creatorId: userId,
});
await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
});
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -180,6 +238,69 @@ describe("custom widget definition persistence", () => {
expect(storedSecret).toEqual(originalSecret);
});
test("keeps the existing secret when setting its replacement fails", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectSecretInserts(db);
await expect(
caller.secretSet({
definitionId: created.id,
secret: { ...secret, value: "replacement-secret" },
}),
).rejects.toThrow("forced secret insert failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("keeps the existing secret when clearing it cannot update the definition revision", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectDefinitionUpdates(db);
await expect(
caller.secretClear({
definitionId: created.id,
sourceId: secret.sourceId,
kind: secret.kind,
}),
).rejects.toThrow("forced definition update failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("configures a source URL and credential together", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -249,7 +370,7 @@ describe("custom widget definition persistence", () => {
};
await expect(
createManageOnlyCaller(db).previewCreate({
createNonAdminCaller(db).previewCreate({
definitionId: created.id,
definition: changedDefinition,
secrets: [],
@@ -260,11 +381,11 @@ describe("custom widget definition persistence", () => {
).resolves.toMatchObject({ success: true });
});
test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
test("clears persisted credentials when an admin source edit changes its binding", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).sourceConfigure({
await createCaller(db).sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "https://other.example/api",
@@ -279,12 +400,109 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
test("uses the persisted source binding from inside the configuration transaction", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const defaultSource = definition.sources.default;
if (!defaultSource) throw new Error("Jellyfin default source is missing");
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: { ...defaultSource, baseUrl: "https://changed.example/api" },
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: defaultSource.baseUrl,
networkScope: defaultSource.networkScope,
secrets: [],
expectedSource: defaultSource,
}),
).resolves.toMatchObject({
status: "configured",
source: { baseUrl: defaultSource.baseUrl },
});
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: { default: { baseUrl: defaultSource.baseUrl } },
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const expectedSource = definition.sources.default;
if (!expectedSource || typeof expectedSource.auth === "string") {
throw new Error("Jellyfin default source must use header authentication");
}
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: {
...expectedSource,
auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
},
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: "https://configured.example/api",
networkScope: "public",
secrets: [secret],
expectedSource,
}),
).resolves.toEqual({ status: "binding-changed" });
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: {
default: {
baseUrl: expectedSource.baseUrl,
auth: { name: "X-Replaced-Credential" },
},
},
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("clears persisted credentials when a general definition update changes its binding", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).update({
await createCaller(db).update({
id: created.id,
sources: {
...jellyfin.sources,
@@ -299,12 +517,12 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
test("requires secret-write permission to create a stored-definition setup link", async () => {
test("requires an administrator to create a stored-definition setup link", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
await expect(
createManageOnlyCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
createNonAdminCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { stringify as stringifySuperJson } from "superjson";
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
@@ -9,14 +10,14 @@ describe("legacy custom widget migration prompt", () => {
id: "legacy",
name: "Private service",
description: "Status",
iconUrl: null,
iconUrl: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
authType: "bearer",
headerName: null,
method: "POST",
requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
displayType: "singleValue",
displayConfig: JSON.stringify({ json: { type: "singleValue", jsonPath: "$.status" } }),
displayConfig: "corrupted-config-with-display-secret",
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
@@ -32,5 +33,40 @@ describe("legacy custom widget migration prompt", () => {
expect(prompt).not.toContain("body-secret");
expect(prompt).not.toContain("admin:password");
expect(prompt).not.toContain("path-token");
expect(prompt).not.toContain("icon-secret");
expect(prompt).not.toContain("icons:password");
expect(prompt).not.toContain("private/icon.svg");
expect(prompt).not.toContain("display-secret");
});
test("omits valid free-text display configuration from the migration prompt", () => {
const prompt = buildLegacyCustomWidgetMigrationPrompt(
{
id: "legacy-custom-jsx",
name: "Private service",
description: "Status",
iconUrl: null,
url: "https://example.test/api/status",
authType: "bearer",
headerName: null,
method: "GET",
requestBody: null,
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
template: "<Text>LEGACY_PRIVATE_MARKER Bearer sk-secret-123456</Text>",
}),
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
creatorId: null,
},
["apiKey"],
);
expect(prompt).toContain("display configuration was intentionally omitted");
expect(prompt).not.toContain("LEGACY_PRIVATE_MARKER");
expect(prompt).not.toContain("Bearer sk-secret-123456");
expect(prompt).not.toContain("sk-secret-123456");
});
});
@@ -0,0 +1,113 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
get: vi.fn(async (_id: string): Promise<unknown> => undefined),
list: vi.fn(async (_options: unknown): Promise<unknown> => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => mocks.logger,
}));
vi.mock("@homarr/workshop/backend", () => ({
WorkshopBackend: class {
public get(id: string) {
return mocks.get(id);
}
public list(options: unknown) {
return mocks.list(options);
}
},
}));
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
const session = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const createCaller = () =>
customWidgetRouter.createCaller({
db: createDb(),
deviceType: undefined,
session,
});
const createTokenizedFailure = (apiKey: string) => {
const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
error.name = `WorkshopTransportError:${tokenizedUrl}`;
return error;
};
const expectSafeFailure = (
error: unknown,
apiKey: string,
logMessage: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
) => {
expect(error).toMatchObject({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
expect((error as Error).cause).toBeUndefined();
expect(String(error)).not.toContain(apiKey);
expect(String(error)).not.toContain("access_token");
expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
event,
errorName: "WorkshopBackendError",
});
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
};
describe("Workshop backend error privacy", () => {
beforeEach(() => {
mocks.get.mockReset();
mocks.list.mockReset();
mocks.logger.error.mockClear();
});
test("redacts backend lookup failures from TRPC output and logs", async () => {
const apiKey = "workshop-lookup-secret";
mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopGet({ submissionId: "submission-id" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
});
test("redacts backend search failures from TRPC output and logs", async () => {
const apiKey = "workshop-search-secret";
mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopSearch({ query: "weather" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
});
});
@@ -0,0 +1,107 @@
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { optionsRouter } from "../../widgets/options";
const createSession = (userId: string, isAdmin = false): Session => ({
user: {
id: userId,
permissions: isAdmin ? ["admin"] : [],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(kind: "customApi" | "weather") {
const db = createDb();
const userId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
await db.insert(items).values({
id: itemId,
boardId,
kind,
options: SuperJSON.stringify(
kind === "customApi"
? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
: { location: "Paris" },
),
});
return { db, userId, boardId, itemId };
}
describe("saveItemOptions Custom Widget access", () => {
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: {
definitionId: "definition-2",
refreshInterval: 1,
configuration: { server: "attacker" },
},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
});
});
test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
).resolves.toBeUndefined();
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
});
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
const options = SuperJSON.parse<Record<string, unknown>>(stored?.options ?? "");
delete options.configuration;
delete options.configurationVersion;
await db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.where(eq(items.id, itemId));
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: { configuration: {}, configurationVersion: 1 },
}),
).resolves.toBeUndefined();
});
test("preserves ordinary board-modifier option updates for other widgets", async () => {
const { db, userId, boardId, itemId } = await setup("weather");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
).resolves.toBeUndefined();
});
});
+8 -2
View File
@@ -9,6 +9,7 @@ import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
@@ -46,10 +47,15 @@ export const optionsRouter = createTRPCRouter({
}
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
Object.assign(options, input.newOptions);
const updatedOptions = { ...options, ...input.newOptions };
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
storedItems: [{ id: item.id, kind: item.kind, options }],
});
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
}),
});
+32 -19
View File
@@ -70,7 +70,6 @@ const MCP_TOOL_ALLOWLIST = {
"board_setHomeBoard",
"board_setMobileHomeBoard",
"customWidget_delete",
"customWidget_deleteLegacy",
"customWidget_previewAction",
"customWidget_templatePatch",
"dnsHole_disable",
@@ -171,19 +170,11 @@ describe("custom widget authoring procedure access", () => {
session: null,
});
test("executes promised public procedures without a session", async () => {
await expect(unauthenticatedCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
await expect(unauthenticatedCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
version: 2,
prompt: expect.any(String),
});
await expect(unauthenticatedCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
});
test("rejects protected and permission-gated authoring procedures without a session", async () => {
test("rejects every authoring procedure without a session", async () => {
const calls = [
unauthenticatedCaller.customWidget.schema(),
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
unauthenticatedCaller.customWidget.getSkill(),
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
unauthenticatedCaller.customWidget.secretSet({
@@ -196,17 +187,39 @@ describe("custom widget authoring procedure access", () => {
}
});
test("enforces the dedicated permission on secret tools", async () => {
const callerWithoutSecretPermission = mcpRouter.createCaller({
test("rejects authoring and secret tools for authenticated non-admins", async () => {
const nonAdminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "user-1", permissions: ["custom-widget-manage"] } } as never,
session: { user: { id: "user-1", permissions: ["board-modify-all"] } } as never,
});
await expect(
callerWithoutSecretPermission.customWidget.secretSet({
const calls = [
nonAdminCaller.customWidget.schema(),
nonAdminCaller.customWidget.getAuthoringPrompt(),
nonAdminCaller.customWidget.getSkill(),
nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
}),
).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
];
for (const call of calls) {
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
}
});
test("allows admins to read authoring resources", async () => {
const adminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "admin-1", permissions: ["admin"] } } as never,
});
await expect(adminCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
await expect(adminCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
version: 2,
prompt: expect.any(String),
});
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
});
});
@@ -267,15 +267,7 @@ export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
}
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
const { template, ...manifest } = widget;
await Promise.all([
writeFile(
path.join(directory, `${basename}.widget.json`),
JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2),
"utf8",
),
writeFile(path.join(directory, `${basename}.widget.jsx`), template, "utf8"),
]);
await writeFile(path.join(directory, `${basename}.widget.json`), JSON.stringify(widget, null, 2), "utf8");
}
async function callOpenRouter(args: {
+48 -22
View File
@@ -1,12 +1,17 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
import {
getCustomWidgetCredentialKeyRisk,
isHarmlessCustomWidgetCredentialSetting,
redactCustomWidgetCredentialLiterals,
} from "./definition-security";
import { customJsxExamples } from "./examples";
export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVersion;
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
"Return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Do not include prose outside those blocks.";
"Return exactly one complete `json` fenced block containing the entire widget. Put the complete JSX source directly in the `template` string and JSON-escape it correctly. Do not include prose or additional code blocks.";
const RECOMMENDED_COMPONENTS = [
"Stack",
@@ -76,14 +81,13 @@ const leanShape = `{
"category": { "label": "Category", "control": "text", "default": "all" },
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
},
"template": "__HOMARR_TEMPLATE__"
"template": "<Stack><Text>{data.items?.name}</Text></Stack>"
}`;
function compactExample(index: number) {
const example = customJsxExamples[index];
if (!example) return "";
const { template, ...manifest } = example.widget;
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``;
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify(example.widget, null, 2)}\n\`\`\``;
}
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
@@ -93,7 +97,7 @@ ${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"}. 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.
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 is valid only for actions, 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.
@@ -117,7 +121,7 @@ ${compactExample(0)}
${compactExample(1)}
Output the manifest with template set exactly to "__HOMARR_TEMPLATE__", then put the complete template in the JSX block.`;
Output one complete JSON manifest. Put the complete JSX directly in its template string so the user can copy one code block and paste it into Homarr once.`;
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
@@ -165,25 +169,42 @@ export function buildCustomWidgetAiPrompt(
return `${truncatePromptText(sections.join("\n\n"), CUSTOM_WIDGET_AI_PROMPT_LIMIT - footer.length)}${footer}`;
}
const sensitiveKey =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
function redactValue(value: unknown, key = ""): unknown {
if (sensitiveKey.test(key)) return "[REDACTED]";
if (Array.isArray(value)) return value.map((entry) => redactValue(entry));
function redactValue(value: unknown, path: Array<string | number> = []): unknown {
if (Array.isArray(value)) return value.map((entry, index) => redactValue(entry, [...path, index]));
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([entryKey, entry]) => [entryKey, redactValue(entry, entryKey)]),
Object.entries(value).map(([entryKey, entry]) => {
const entryPath = [...path, entryKey];
const risk = getCustomWidgetCredentialKeyRisk(entryKey);
if (
risk === "strong" &&
!isSchemaAuthenticationControl(entryPath) &&
!isHarmlessCustomWidgetCredentialSetting(entry)
) {
return [entryKey, "[REDACTED]"];
}
if (
risk === "ambiguous" &&
typeof entry === "string" &&
redactCustomWidgetCredentialLiterals(entry) !== entry
) {
return [entryKey, "[REDACTED]"];
}
return [entryKey, redactValue(entry, entryPath)];
}),
);
if (typeof value === "string") {
const key = typeof path.at(-1) === "string" ? (path.at(-1) as string) : "";
if (["sources", "requests", "options"].includes(key)) {
try {
return redactValue(JSON.parse(value) as unknown);
return redactValue(JSON.parse(value) as unknown, path);
} catch {
/* Keep invalid editor JSON useful. */
}
}
return key === "baseUrl" || key === "iconUrl" ? redactUrl(value) : redactText(value);
return key === "baseUrl" || key === "iconUrl"
? redactCustomWidgetCredentialLiterals(redactUrl(value))
: redactText(value);
}
return value;
}
@@ -210,13 +231,18 @@ function redactUrl(value: string): string {
}
function redactText(value: string): string {
return value
.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactUrl(candidate))
.replace(/\b(authorization\s*:\s*bearer)\s+[^\s,;]+/giu, "$1 [REDACTED]")
.replace(
/\b(api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)["']?\s*[:=]\s*["']?[^\s,;"'}]+/giu,
"$1: [REDACTED]",
);
return redactCustomWidgetCredentialLiterals(
value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactUrl(candidate)),
);
}
function isSchemaAuthenticationControl(path: Array<string | number>): boolean {
return (
path.length === 3 &&
path[2] === "auth" &&
(path[0] === "sources" || path[0] === "requests") &&
typeof path[1] === "string"
);
}
function formatBudgetedCodeSection(label: string, content: string, budget: number): string | null {
@@ -12,6 +12,7 @@ import {
} from "./request-schema";
import type { CustomJsxRequest } from "./request-schema";
import { customWidgetSecretKinds } from "./schema-types";
import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export * from "./options-schema";
export * from "./request-schema";
@@ -67,12 +68,12 @@ export const customWidgetDefinitionSchema = z
description: z.string().max(512).optional(),
iconUrl: z
.string()
.refine(
(value) => getCustomWidgetHttpUrlIssue(value) === null,
"Widget icons must use an unambiguous HTTP or HTTPS URL without embedded credentials",
)
.url()
.refine((value) => {
if (!URL.canParse(value)) return false;
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
}, "Widget icons must use HTTP or HTTPS without embedded credentials")
.max(2048)
.optional(),
sources: customWidgetSourcesSchema,
requests: customWidgetRequestsSchema,
@@ -1,38 +1,210 @@
import type { z } from "zod/v4";
const credentialKey =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
type DefinitionPath = Array<string | number>;
const harmlessCredentialSettings = new Set([
"anonymous",
"authentication",
"basic",
"bearer",
"configured",
"default",
"disabled",
"enabled",
"example",
"false",
"inherit",
"missing",
"none",
"optional",
"placeholder",
"public",
"redacted",
"required",
"separate",
"separately",
"source",
"true",
"unset",
]);
const commonCredentialLiteralPattern =
/\b(?:sk|pk|rk)-(?:[A-Za-z0-9][A-Za-z0-9._-]{7,})\b|\b(?:sk_(?:live|test)|github_pat|glpat|gh[pousr]|hf_|xox[baprs])-?[A-Za-z0-9._-]{8,}\b|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\bAIza[0-9A-Za-z_-]{20,}\b|\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/giu;
const authorizationSchemePattern = /\b(bearer|basic)([\s:_-]+)(["']?)([A-Za-z0-9._~+/%=-]{8,})/giu;
const credentialAssignmentPattern =
/\b(authorization|auth(?:entication)?(?:[ _-]?(?:tokens?|keys?|credentials?))?|credentials?|api[ _-]?keys?|passwords?|passwds?|secrets?|tokens?|access[ _-]?(?:tokens?|keys?)|refresh[ _-]?tokens?|client[ _-]?secrets?|private[ _-]?keys?|signing[ _-]?keys?)((?:["']?\s*[:=]\s*["']?))([^\s,;"'}<>]+)/giu;
export function validateCredentialFreeExport(definition: Record<string, unknown>, ctx: z.RefinementCtx) {
inspect(definition.options, ["options"], ctx);
inspect(definition.requests, ["requests"], ctx);
if (typeof definition.iconUrl === "string" && URL.canParse(definition.iconUrl)) {
const url = new URL(definition.iconUrl);
if ([...url.searchParams.keys()].some((key) => credentialKey.test(key))) {
ctx.addIssue({ code: "custom", path: ["iconUrl"], message: "Credentials must not be embedded in the icon URL" });
const { secrets: _secrets, ...exportedDefinition } = definition;
inspect(exportedDefinition, [], ctx);
inspectRequestPathQueries(definition.requests, ctx);
inspectUrlQuery(definition.iconUrl, ["iconUrl"], "Widget icon URLs", ctx);
}
function inspect(value: unknown, path: DefinitionPath, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
if (typeof value === "string") {
if (containsCustomWidgetCredentialLiteral(value)) {
ctx.addIssue({ code: "custom", path, message: "Credentials must use source authentication" });
}
return;
}
if (
typeof definition.template === "string" &&
/(?:authorization\s*:\s*bearer|api[-_ ]?key\s*[:=]|password\s*[:=]|secret\s*[:=]|token\s*[:=])\s*["']?[A-Za-z0-9._~+/%=-]{8,}/iu.test(
definition.template,
)
) {
ctx.addIssue({ code: "custom", path: ["template"], message: "Credentials must not be embedded in JSX" });
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
const keyRisk = getCustomWidgetCredentialKeyRisk(key);
if (
keyRisk === "strong" &&
!isSchemaAuthenticationControl(childPath) &&
hasCredentialFieldValue(child) &&
!isHarmlessCustomWidgetCredentialSetting(child)
) {
ctx.addIssue({ code: "custom", path: childPath, message: "Credentials must use source authentication" });
continue;
}
if (keyRisk === "ambiguous" && typeof child === "string" && containsCustomWidgetCredentialLiteral(child)) {
ctx.addIssue({ code: "custom", path: childPath, message: "Credentials must use source authentication" });
continue;
}
inspect(child, childPath, ctx);
}
}
function inspect(value: unknown, path: Array<string | number>, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (credentialKey.test(key) && typeof child === "string" && child.trim()) {
ctx.addIssue({ code: "custom", path: [...path, key], message: "Credentials must use source authentication" });
function inspectRequestPathQueries(value: unknown, ctx: z.RefinementCtx): void {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const [requestId, candidate] of Object.entries(value)) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
const path = (candidate as Record<string, unknown>).path;
if (typeof path !== "string") continue;
const queryStart = path.indexOf("?");
if (queryStart < 0) continue;
const query = path.slice(queryStart + 1);
for (const [key, queryValue] of parseQueryEntries(query)) {
const keyRisk = getCustomWidgetCredentialKeyRisk(key);
if (
(keyRisk === "strong" && queryValue.length > 0 && !isHarmlessCustomWidgetCredentialSetting(queryValue)) ||
containsCustomWidgetCredentialLiteral(queryValue)
) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "path"],
message: "Request query credentials must use source authentication",
});
break;
}
}
inspect(child, [...path, key], ctx);
}
}
function inspectUrlQuery(value: unknown, path: DefinitionPath, label: string, ctx: z.RefinementCtx): void {
if (typeof value !== "string" || !URL.canParse(value)) return;
const url = new URL(value);
if (
parseQueryEntries(url.search).some(
([key, queryValue]) =>
(getCustomWidgetCredentialKeyRisk(key) === "strong" && queryValue.length > 0) ||
containsCustomWidgetCredentialLiteral(queryValue),
)
) {
ctx.addIssue({ code: "custom", path, message: `${label} cannot contain credentials` });
}
}
function parseQueryEntries(search: string): Array<[string, string]> {
const query = search.startsWith("?") ? search.slice(1) : search;
if (!query) return [];
return query.split("&").map((entry) => {
const separatorIndex = entry.indexOf("=");
const key = separatorIndex < 0 ? entry : entry.slice(0, separatorIndex);
const value = separatorIndex < 0 ? "" : entry.slice(separatorIndex + 1);
return [decodeQueryPart(key), decodeQueryPart(value)];
});
}
function decodeQueryPart(value: string): string {
try {
return decodeURIComponent(value.replaceAll("+", " "));
} catch {
return value;
}
}
export function getCustomWidgetCredentialKeyRisk(key: string): "strong" | "ambiguous" | null {
const normalized = key
.replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
.replace(/[^A-Za-z0-9]+/gu, " ")
.trim()
.toLowerCase();
if (!normalized) return null;
const phrases = [
"authorization",
"authentication",
"auth",
"credential",
"credentials",
"api key",
"api keys",
"password",
"passwords",
"passwd",
"passwds",
"secret",
"secrets",
"token",
"tokens",
"access token",
"access tokens",
"access key",
"access keys",
"refresh token",
"refresh tokens",
"client secret",
"client secrets",
"private key",
"private keys",
"signing key",
"signing keys",
];
if (phrases.some((phrase) => normalized === phrase || normalized.endsWith(` ${phrase}`))) return "strong";
return normalized === "key" || normalized.endsWith(" key") || normalized === "keys" || normalized.endsWith(" keys")
? "ambiguous"
: null;
}
export function isHarmlessCustomWidgetCredentialSetting(value: unknown): boolean {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return true;
return typeof value === "string" && harmlessCredentialSettings.has(value.trim().toLowerCase());
}
export function containsCustomWidgetCredentialLiteral(value: string): boolean {
return redactCustomWidgetCredentialLiterals(value) !== value;
}
export function redactCustomWidgetCredentialLiterals(value: string): string {
return value
.replace(commonCredentialLiteralPattern, "[REDACTED]")
.replace(authorizationSchemePattern, (match, scheme: string, separator: string, quote: string, candidate: string) =>
isHarmlessCustomWidgetCredentialSetting(candidate) ? match : `${scheme}${separator}${quote}[REDACTED]`,
)
.replace(credentialAssignmentPattern, (match, key: string, separator: string, candidate: string) =>
isHarmlessCustomWidgetCredentialSetting(candidate) ? match : `${key}${separator}[REDACTED]`,
);
}
function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
return (
path.length === 3 &&
path[2] === "auth" &&
(path[0] === "sources" || path[0] === "requests") &&
typeof path[1] === "string"
);
}
function hasCredentialFieldValue(value: unknown): boolean {
if (value === null || value === undefined || value === "") return false;
return true;
}
export function validatePrototypeKeys(value: unknown, ctx: z.RefinementCtx) {
walk(value, [], ctx);
}
+5 -35
View File
@@ -69,10 +69,6 @@ function removedStateIssues(value: Record<string, unknown>): CustomWidgetImportI
);
}
function isTemplatePlaceholder(value: unknown) {
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
}
type ParsedWidgetValue =
| { success: true; value: Record<string, unknown> }
| { success: false; issue: CustomWidgetImportIssue };
@@ -100,24 +96,10 @@ export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 |
export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlock = blocks.find((block) => block.language === "json" || block.language === "");
const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx");
const source = jsonBlock?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] };
const widget = { ...parsedValue.value };
if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) {
return {
success: false,
issues: [
{
code: "MISSING_JSX_BLOCK",
path: ["template"],
message: "The manifest uses a template placeholder but no fenced JSX block was provided.",
},
],
};
}
if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim();
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
@@ -128,20 +110,17 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === "");
const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx");
const formatIssues: CustomWidgetImportIssue[] = [];
if (jsonBlocks.length !== 1)
formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." });
if (jsxBlocks.length !== 1)
formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." });
if (blocks.length !== jsonBlocks.length)
formatIssues.push({
code: "AI_SINGLE_JSON_BLOCK_REQUIRED",
message: "Expected one JSON block only. Put the complete JSX directly in its template field.",
});
const source = jsonBlocks[0]?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) {
if (/\\_/u.test(source))
formatIssues.push({
code: "INVALID_JSON_ESCAPE",
message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".',
});
if (/"(?:stateSchema|defaultState)"\s*:/u.test(source))
formatIssues.push({
code: "REMOVED_LOCAL_STATE",
@@ -153,15 +132,6 @@ export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResu
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
const warnings: CustomWidgetImportIssue[] = [];
if (jsxBlocks[0]) {
if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
else if (widget.template !== jsxBlocks[0].content.trim())
formatIssues.push({
code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED",
path: ["template"],
message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.',
});
}
if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { customWidgetMethods } from "./schema-types";
import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
@@ -26,10 +27,9 @@ const authSchema = z.union([
export type CustomWidgetSourceUrlIssue = "invalid" | "protocol" | "credentials" | "queryOrFragment";
export function getCustomWidgetSourceUrlIssue(value: string): CustomWidgetSourceUrlIssue | null {
if (!URL.canParse(value)) return "invalid";
const issue = getCustomWidgetHttpUrlIssue(value);
if (issue) return issue;
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol";
if (url.username || url.password) return "credentials";
if (url.search || url.hash) return "queryOrFragment";
return null;
}
@@ -45,12 +45,12 @@ export const customWidgetSourceSchema = z.strictObject({
name: z.string().trim().min(1).max(128).optional(),
baseUrl: z
.string()
.url()
.max(2048)
.superRefine((value, ctx) => {
const issue = getCustomWidgetSourceUrlIssue(value);
if (issue && issue !== "invalid") ctx.addIssue({ code: "custom", message: sourceUrlIssueMessages[issue] });
}),
if (issue) ctx.addIssue({ code: "custom", message: sourceUrlIssueMessages[issue] });
})
.url()
.max(2048),
networkScope: z.enum(customJsxNetworkScopes),
auth: authSchema.default("none"),
});
@@ -157,6 +157,15 @@ export const customJsxRequestSchema = z
confirmation: confirmationSchema.optional(),
invalidates: z.array(customWidgetIdentifierSchema).max(32).optional(),
})
.superRefine((request, context) => {
if (request.method === "DELETE" && request.kind !== "action") {
context.addIssue({
code: "custom",
path: ["kind"],
message: "DELETE requests must be manual actions",
});
}
})
.transform((request) => {
const confirmation =
request.method === "DELETE" && request.confirmation === undefined
@@ -0,0 +1,116 @@
export type CustomWidgetHttpUrlIssue = "invalid" | "protocol" | "credentials";
const forbiddenUnicodeCodePointRanges = [
[0x0000, 0x0020],
[0x007f, 0x00a0],
[0x1680, 0x1680],
[0x2000, 0x200f],
[0x2028, 0x202f],
[0x205f, 0x206f],
[0x3000, 0x3000],
[0xfeff, 0xfeff],
] as const;
/**
* Applies the conservative URL syntax shared by Homarr and the Workshop
* validator. WHATWG URL parsing intentionally accepts and normalizes several
* ambiguous spellings (for example IPv4 integers, backslashes and escaped
* hostnames). Definitions are persisted and later used for network requests,
* so authoring accepts only an unambiguous authority spelling.
*/
export function getCustomWidgetHttpUrlIssue(value: string): CustomWidgetHttpUrlIssue | null {
if (hasForbiddenUrlCharacter(value) || value.includes("\\")) return "invalid";
if (!URL.canParse(value)) return "invalid";
let url: URL;
try {
url = new URL(value);
} catch {
return "invalid";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol";
const authority = getRawAuthority(value);
if (authority === null) return "invalid";
if (authority.includes("@") || url.username || url.password) return "credentials";
if (authority.includes("%") || /[。.。]/u.test(authority)) return "invalid";
const hostAndPort = splitHostAndPort(authority);
if (!hostAndPort || !hasCanonicalPort(hostAndPort.port)) return "invalid";
const rawHostname = withoutTrailingRootDot(hostAndPort.hostname);
const parsedHostname = withoutTrailingRootDot(url.hostname);
if (!rawHostname || !hasCanonicalParsedHostname(parsedHostname)) return "invalid";
if (
rawHostname.startsWith("[") &&
parsedHostname.startsWith("[") &&
!rawHostname.includes(".") &&
rawHostname.toLowerCase() !== parsedHostname
) {
return "invalid";
}
if (looksNumeric(rawHostname) && !isStrictIpv4(rawHostname)) return "invalid";
if (isStrictIpv4(parsedHostname) && rawHostname !== parsedHostname) return "invalid";
return null;
}
function getRawAuthority(value: string): string | null {
const match = value.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/u);
return match?.[1] ?? null;
}
function splitHostAndPort(authority: string): { hostname: string; port: string } | null {
if (authority.startsWith("[")) {
const closingBracket = authority.indexOf("]");
if (closingBracket < 0) return null;
const hostname = authority.slice(0, closingBracket + 1);
const suffix = authority.slice(closingBracket + 1);
if (suffix && !suffix.startsWith(":")) return null;
return { hostname, port: suffix ? suffix.slice(1) : "" };
}
const firstColon = authority.indexOf(":");
if (firstColon !== authority.lastIndexOf(":")) return null;
return {
hostname: firstColon < 0 ? authority : authority.slice(0, firstColon),
port: firstColon < 0 ? "" : authority.slice(firstColon + 1),
};
}
function hasCanonicalPort(value: string): boolean {
if (!value) return true;
if (!/^(?:0|[1-9]\d{0,4})$/u.test(value)) return false;
return Number(value) <= 65_535;
}
function hasCanonicalParsedHostname(value: string): boolean {
if (value.startsWith("[") && value.endsWith("]")) return true;
if (!value || value.length > 253 || !/^[A-Za-z0-9._-]+$/u.test(value)) return false;
return value
.split(".")
.every((label) => Boolean(label) && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-"));
}
function looksNumeric(value: string): boolean {
const parts = value.split(".");
return parts.every((part) => /^(?:\d+|0[xX][0-9A-Fa-f]+)$/u.test(part));
}
function isStrictIpv4(value: string): boolean {
const parts = value.split(".");
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9]\d{0,2})$/u.test(part) && Number(part) <= 255);
}
function withoutTrailingRootDot(value: string): string {
return value.endsWith(".") ? value.slice(0, -1) : value;
}
function hasForbiddenUrlCharacter(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return forbiddenUnicodeCodePointRanges.some(([minimum, maximum]) => codePoint >= minimum && codePoint <= maximum);
});
}
+4 -2
View File
@@ -10,17 +10,19 @@ export type CustomWidgetDomainErrorCode =
export class CustomWidgetDomainError extends Error {
public readonly code: CustomWidgetDomainErrorCode;
public readonly reason?: "timeout";
public readonly retryAfterMs?: number;
public constructor(input: {
code: CustomWidgetDomainErrorCode;
message: string;
cause?: unknown;
reason?: "timeout";
retryAfterMs?: number;
}) {
super(input.message, { cause: input.cause });
super(input.message);
this.name = "CustomWidgetDomainError";
this.code = input.code;
this.reason = input.reason;
this.retryAfterMs = input.retryAfterMs;
}
}
+6 -1
View File
@@ -16,7 +16,12 @@ export {
validateCustomWidgetUrl,
} from "./request-executor";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
export { CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, CustomWidgetRequestLimiter } from "./request-limits";
export {
CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
CustomWidgetRequestLimiter,
} from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
export type {
@@ -66,6 +66,11 @@ loopbackAddresses.addAddress("::1", "ipv6");
type AddressFamily = 4 | 6;
export type ResolvedAddress = { address: string; family: AddressFamily };
export type HostResolver = (hostname: string) => Promise<ResolvedAddress[]>;
export interface ResolveHostOptions {
signal?: AbortSignal;
resolver?: HostResolver;
}
type AddressClass = "public" | "private" | "loopback" | "blocked";
const normalizeHostname = (value: string) =>
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
@@ -83,12 +88,19 @@ export function classifyAddress(address: string): AddressClass {
export async function resolveAndValidateHost(
hostname: string,
scope: CustomJsxNetworkScope,
options: ResolveHostOptions = {},
): Promise<ResolvedAddress[]> {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized) as AddressFamily | 0;
throwIfAborted(options.signal);
const addresses = family
? [{ address: normalized, family }]
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
: await abortable(
options.resolver?.(normalized) ??
(lookup(normalized, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
options.signal,
);
throwIfAborted(options.signal);
if (!addresses.length)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
for (const address of addresses) {
@@ -106,6 +118,39 @@ export async function resolveAndValidateHost(
return addresses;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw signal.reason ?? createAbortError();
}
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return operation;
if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener("abort", onAbort);
reject(signal.reason ?? createAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
function createAbortError(): Error {
const error = new Error("Operation aborted");
error.name = "AbortError";
return error;
}
export function validateCustomWidgetUrl(value: string | URL): URL {
let url: URL;
try {
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more