feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

-2
View File
@@ -1,3 +1 @@
pb_data/
.env
.env.local
+30 -46
View File
@@ -1,54 +1,38 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS docs
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
ENV COREPACK_HOME=/corepack
RUN mkdir -p /corepack && corepack enable pnpm && corepack install --global pnpm@11.11.0
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY patches ./patches
COPY apps/docs ./apps/docs
COPY packages/custom-widgets ./packages/custom-widgets
COPY packages/definitions ./packages/definitions
COPY packages/workshop ./packages/workshop
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
RUN corepack enable
FROM dependencies AS website
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
COPY . .
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile --filter @homarr/docs...
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
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
ENTRYPOINT ["workshop-entrypoint"]
FROM alpine:3.22 AS pocketbase
ARG POCKETBASE_VERSION=0.39.6
ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && \
grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY --from=docs /app/apps/docs/build /pb_public
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN addgroup -S workshop && \
adduser -S -G workshop -h /home/workshop workshop && \
mkdir -p /pb_data && \
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
USER workshop
FROM pocketbase-runtime AS development
EXPOSE 8090
VOLUME ["/pb_data"]
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \
CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"]
FROM pocketbase-runtime AS production
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/pb_migrations /pb_migrations
COPY apps/workshop/pb_hooks /pb_hooks
RUN chown -R pocketbase:pocketbase /pb_hooks /pb_migrations /pb_public
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
+4 -12
View File
@@ -1,15 +1,7 @@
.git
.github
.env
.env.*
!.env.example
**/.docusaurus
**/.next
**/.turbo
**/build
**/coverage
**/dist
.next
.turbo
**/node_modules
**/pb_data
.pnpm-store
e2e
apps/docs/build
coverage
+64 -43
View File
@@ -1,80 +1,101 @@
# Homarr Workshop
Workshop ships as one container. Its build stage compiles the current Docusaurus site, and its runtime stage serves those static files and the PocketBase API from the same origin.
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
## Local production-like run
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.
Configure optional values in the repository-root `.env`, then run:
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.
## Local development
```sh
pnpm dev:workshop
pnpm docker:workshop
```
Open:
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.
- documentation: `http://localhost:8090/`;
- Workshop: `http://localhost:8090/workshop/`;
- PocketBase API: `http://localhost:8090/api/`;
- PocketBase dashboard: `http://localhost:8090/_/`.
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>`.
The `homarr_workshop_deployment_data` volume persists records and uploaded files across image rebuilds. Migrations run automatically when PocketBase starts. It is intentionally separate from volumes created by earlier prototypes with incompatible schemas.
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
## GitHub OAuth
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.
Create a GitHub OAuth application with these local values:
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
- homepage: `http://localhost:8090`;
- callback: `http://localhost:8090/api/oauth2-redirect`.
The GitHub OAuth application needs one callback for the Workshop host:
Set these values only in the repository-root `.env`:
```dotenv
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
```text
https://<workshop-host>/api/oauth2-redirect
```
For production, use `https://workshop.homarr.dev` and `https://workshop.homarr.dev/api/oauth2-redirect`. Create the first PocketBase superuser through the installer link printed on first startup or through the PocketBase CLI.
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.
## Local image validation
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.
Build and test the exact runtime shape:
Run the disposable service integration test from the repository root:
```sh
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
pnpm test:workshop-image
pnpm test:workshop
```
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
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.
## GitHub Actions and ACT
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
The 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.
`.github/workflows/deployment-workshop.yml` runs only for changes below `apps/docs/` or `apps/workshop/`, plus manual dispatches. It builds and tests the combined image, then publishes:
## Production docs preview
- `ghcr.io/homarr-labs/workshop:latest`;
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
Run the same build and smoke test locally without registry credentials:
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
```sh
pnpm ci:act:workshop
pnpm docker:docs
```
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
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.
## VPS deployment
Copy `apps/workshop/docker-compose.yml` to the checkout on the VPS, authenticate Docker to GHCR if the package is private, and run from the repository root. Compose reads the optional root `.env` automatically:
Point a local Homarr checkout at it with:
```sh
docker compose -f apps/workshop/docker-compose.yml pull
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
```
The service binds to `127.0.0.1:8090` by default. Put a TLS reverse proxy in front of it for `workshop.homarr.dev`, forward the original scheme and host, and persist the `homarr_workshop_deployment_data` volume. Back up that volume before PocketBase upgrades.
The production Homarr image reads the same variable when the container starts:
## Content schema
```sh
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
```
- Custom widgets use `homarr-custom-widget-v2` JSON.
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
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:
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
```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:
```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.
+29 -11
View File
@@ -2,27 +2,45 @@ name: homarr-workshop
services:
workshop:
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: development
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${PB_EXPOSE_PORT:-8090}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:8090}
ports:
- "127.0.0.1:${WORKSHOP_PORT:-8090}:8090"
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
volumes:
- workshop_data:/pb_data
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8090/api/health"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
- pb_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
docs:
profiles:
- docs
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${DOCS_EXPOSE_PORT:-3003}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
volumes:
- pb_data:/pb_data
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
name: homarr_workshop_deployment_data
pb_data:
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -eq 0 ]; then
chown -R pocketbase:pocketbase /pb_data
exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint'
fi
exec pocketbase serve \
--http="0.0.0.0:${PORT:-8090}" \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New Comment on Homarr Workshop</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4">
<tr>
<td align="center" style="padding: 50px 20px">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 600px; max-width: 100%; background-color: #ffffff; border: 1px solid #000000"
>
<!-- Header -->
<tr>
<td align="center" style="background-color: #000000; padding: 36px 20px">
<a href="https://homarr.dev" target="_blank" style="text-decoration: none">
<img
src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.png"
alt="Homarr"
width="170"
style="display: block; width: 170px; height: auto; border: 0; margin: 0 auto"
/>
</a>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 45px 40px; color: #000000; font-size: 15px; line-height: 1.7">
<p style="margin: 0 0 18px 0">Hi there,</p>
<p style="margin: 0 0 18px 0">
<strong>{{commenterName}}</strong> left a comment on your submission
<strong>{{submissionTitle}}</strong>:
</p>
<blockquote
style="
margin: 0 0 24px 0;
padding: 16px 20px;
border-left: 3px solid #000000;
background-color: #f9f9f9;
color: #555555;
"
>
{{commentExcerpt}}
</blockquote>
<!-- CTA button -->
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="background-color: #000000">
<a
href="{{submissionUrl}}"
target="_blank"
style="
display: inline-block;
padding: 12px 28px;
color: #ffffff;
font-size: 14px;
font-weight: bold;
text-decoration: none;
letter-spacing: 0.5px;
"
>
VIEW THE COMMENT
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="border-top: 1px solid #000000"></td>
</tr>
<!-- Footer -->
<tr>
<td align="center" style="padding: 24px 20px; color: #000000; font-size: 12px">
<a href="https://homarr.dev" target="_blank" style="color: #000000; text-decoration: underline"
>homarr.dev</a
>
&nbsp;&middot;&nbsp; Homarr &mdash; 2026
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workshop report</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4">
<tr>
<td align="center" style="padding: 50px 20px">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 600px; max-width: 100%; background: #ffffff; border: 1px solid #dc2626"
>
<tr>
<td style="background: #991b1b; padding: 28px 36px; color: #ffffff">
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
Moderation alert
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">A Workshop submission was reported</div>
</td>
</tr>
<tr>
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
<p style="margin: 0 0 18px">
<strong>{{reporterName}}</strong> reported <a href="{{submissionUrl}}">{{submissionTitle}}</a>.
</p>
<p style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b">
{{category}}
</p>
<blockquote
style="margin: 0 0 26px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2"
>
{{explanation}}
</blockquote>
<a
href="{{adminUrl}}"
style="display: inline-block; padding: 12px 20px; background: #991b1b; color: #ffffff; font-weight: bold; text-decoration: none"
>
REVIEW REPORT
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workshop submission removed</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4">
<tr>
<td align="center" style="padding: 50px 20px">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 600px; max-width: 100%; background: #ffffff; border: 1px solid #27272a"
>
<tr>
<td style="background: #18181b; padding: 28px 36px; color: #ffffff">
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
Homarr Workshop
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">Your submission was removed</div>
</td>
</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>
<div style="margin: 0 0 24px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2">
<strong>{{submissionTitle}}</strong>
</div>
<p style="margin: 0">
The submission is no longer available in Workshop. Widgets or CSS already installed by users are not
removed from their Homarr instances.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+138 -59
View File
@@ -1,75 +1,154 @@
/// <reference path="../pb_data/types.d.ts" />
// --- Submissions ---
onRecordCreateRequest((e) => {
e.record.set("author", e.auth?.id);
e.record.set("upvotes", 0);
e.record.set("downvotes", 0);
e.record.set("version", 1);
e.next();
}, "submissions");
onRecordAfterCreateSuccess((e) => {
onRecordAfterCreateSuccess((event) => {
try {
var votes = e.app.findCollectionByNameOrId("votes");
var vote = new Record(votes);
vote.set("submission", e.record.id);
vote.set("user", e.record.get("author"));
const votes = event.app.findCollectionByNameOrId("votes");
const vote = new Record(votes);
vote.set("submission", event.record.id);
vote.set("user", event.record.get("author"));
vote.set("value", 1);
e.app.save(vote);
} catch (err) {
console.log("auto-upvote failed for submission " + e.record.id + ": " + err);
event.app.save(vote);
} catch (error) {
console.log(`Workshop initial upvote failed for ${event.record.id}: ${error}`);
}
e.next();
event.next();
}, "submissions");
onRecordUpdateRequest((e) => {
var original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("upvotes", original.get("upvotes"));
e.record.set("downvotes", original.get("downvotes"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set("version", original.get("version"));
e.next();
}, "submissions");
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
// --- Votes ---
const emailTemplate = (filename) => {
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
return $os.readFile($filepath.join(hooksPath, filename));
};
onRecordCreateRequest((e) => {
var v = +e.record.get("value");
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
e.record.set("user", e.auth?.id);
e.next();
}, "votes");
const sendEmail = (app, recipientEmail, subject, text, html) => {
const sender = app.settings().meta;
app.newMailClient().send(
new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject,
text,
html,
}),
);
};
onRecordUpdateRequest((e) => {
var v = +e.record.get("value");
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
var original = e.app.findRecordById("votes", e.record.id);
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordAfterCreateSuccess((event) => {
try {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const recipient = event.app.findRecordById("users", submission.get("author"));
const commenter = event.app.findRecordById("users", event.record.get("author"));
const recipientEmail = recipient.email();
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
// Vote count hooks removed — the `marketplace` view computes upvotes/downvotes via SQL
if (!recipientEmail || recipient.id === commenter.id || !publicOrigin) {
event.next();
return;
}
// --- Reports & Comments ---
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 template = emailTemplate("comment-email.html")
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
.replaceAll("{{commentExcerpt}}", escapeHtml(rawExcerpt))
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
onRecordCreateRequest((e) => {
e.record.set("user", e.auth?.id);
e.next();
sendEmail(
event.app,
recipientEmail,
`${commenterName} commented on ${submissionTitle}`,
`${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
template,
);
} catch (error) {
console.log(`Workshop comment email failed for ${event.record.id}: ${error}`);
}
event.next();
}, "comments");
onRecordAfterCreateSuccess((event) => {
try {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
if (!publicOrigin || admins.length === 0) {
event.next();
return;
}
const reporterName = reporter.getString("displayName") || "A community member";
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 template = emailTemplate("report-email.html")
.replaceAll("{{reporterName}}", escapeHtml(reporterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
.replaceAll("{{category}}", escapeHtml(category))
.replaceAll("{{explanation}}", escapeHtml(explanation))
.replaceAll("{{adminUrl}}", escapeHtml(adminUrl))
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
for (const admin of admins) {
try {
sendEmail(
event.app,
admin.email(),
`Workshop report: ${submissionTitle}`,
`${reporterName} reported “${submissionTitle}” for ${category}.\n\n${explanation}\n\nReview: ${adminUrl}`,
template,
);
} catch (error) {
console.log(`Workshop report email failed for admin ${admin.id}: ${error}`);
}
}
} catch (error) {
console.log(`Workshop report email failed for ${event.record.id}: ${error}`);
}
event.next();
}, "reports");
onRecordCreateRequest((e) => {
e.record.set("author", e.auth?.id);
e.next();
}, "comments");
onRecordDeleteRequest((event) => {
const submissionId = event.record.id;
const submissionTitle = event.record.getString("title");
const authorId = event.record.get("author");
const actor = event.requestInfo().auth;
const notifyAuthor = actor && actor.getBool("isAdmin") && actor.id !== authorId;
onRecordUpdateRequest((e) => {
var original = e.app.findRecordById("comments", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("submission", original.get("submission"));
e.next();
}, "comments");
event.next();
if (!notifyAuthor) return;
try {
const author = event.app.findRecordById("users", authorId);
const recipientEmail = author.email();
if (!recipientEmail) return;
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const workshopUrl = publicOrigin ? `${publicOrigin}/workshop/` : "";
const template = emailTemplate("submission-deleted-email.html").replaceAll(
"{{submissionTitle}}",
escapeHtml(submissionTitle),
);
const text = `A Workshop administrator removed your submission “${submissionTitle}”.${
workshopUrl ? `\n\nReturn to Workshop: ${workshopUrl}` : ""
}`;
sendEmail(event.app, recipientEmail, `Your Workshop submission was removed`, text, template);
} catch (error) {
console.log(`Workshop deletion email failed for ${submissionId}: ${error}`);
}
}, "submissions");
@@ -0,0 +1,218 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
users = new Collection({ type: "auth", name: "users" });
}
users.passwordAuth = { enabled: false };
if (!users.fields.getByName("displayName"))
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
if (!users.fields.getByName("avatar"))
users.fields.add(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
if (!users.fields.getByName("githubUsername"))
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
}
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.deleteRule = null;
app.save(users);
const submissions = new Collection({
type: "base",
name: "submissions",
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`,
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "widgetSchema", required: true, min: 1, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "bool", name: "outdated" },
{
type: "file",
name: "screenshots",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
thumbs: ["480x320", "960x640"],
},
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_created", false, "created", "");
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule:
"@request.auth.id != '' && @request.body.user = @request.auth.id && (@request.body.value = 1 || @request.body.value = -1)",
updateRule:
"user = @request.auth.id && @request.body.user:changed = false && @request.body.submission:changed = false && (@request.body.value = 1 || @request.body.value = -1)",
deleteRule: "user = @request.auth.id",
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const comments = new Collection({
type: "base",
name: "comments",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule:
"author = @request.auth.id && @request.body.author:changed = false && @request.body.submission:changed = false",
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "text", name: "content", required: true, min: 1, max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
comments.addIndex("idx_comments_submission", false, "submission", "");
app.save(comments);
const reports = new Collection({
type: "base",
name: "reports",
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`,
deleteRule: adminRule,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
type: "relation",
name: "reporter",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "select",
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "dismissed"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
reports.addIndex("idx_reports_created", false, "created", "");
app.save(reports);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.widgetSchema, s.screenshots, s.author,
u.displayName AS authorName, u.avatar AS authorAvatar, u.avatarUrl AS authorAvatarUrl,
u.githubUsername AS authorGithubUsername, u.githubProfileUrl AS authorGithubProfileUrl,
s.revision, s.changelog, s.outdated, s.created, s.updated,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = 1), 0) AS upvotes,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = -1), 0) AS downvotes,
COALESCE((SELECT SUM(v.value) FROM votes v WHERE v.submission = s.id), 0) AS score,
COALESCE((SELECT COUNT(*) FROM comments c WHERE c.submission = s.id), 0) AS commentCount,
COALESCE((SELECT COUNT(*) FROM reports r WHERE r.submission = s.id AND r.status = 'open'), 0) AS reportCount
FROM submissions s
JOIN users u ON u.id = s.author
`,
});
app.save(listings);
const settings = app.settings();
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) => {
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {}
}
},
);
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up --build -d
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml logs --no-color workshop
exit 1
fi
sleep 1
done
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
pocketbase superuser create \
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
@@ -0,0 +1,305 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return body;
};
const expectStatus = async (path, init, expected) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
if (response.status !== expected) {
const body = await response.text();
throw new Error(`Expected ${expected} for ${path}, received ${response.status}: ${body}`);
}
};
const root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
});
const rootHeaders = { authorization: `Bearer ${root.token}` };
const collections = await request("/api/collections?perPage=200", { headers: rootHeaders });
const collectionNames = new Set(collections.items.map((collection) => collection.name));
for (const required of ["submissions", "votes", "comments", "reports", "workshop_listings"]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
}
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
if (usersCollection.createRule !== '@request.context = "oauth2" && @request.body.isAdmin:isset = false') {
throw new Error("User creation must be limited to OAuth without administrator fields");
}
await request("/api/collections/users", {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
});
const createUser = async (email, displayName, password, extra = {}) =>
request("/api/collections/users/records", {
method: "POST",
headers: rootHeaders,
body: JSON.stringify({
email,
emailVisibility: false,
verified: true,
password,
passwordConfirm: password,
displayName,
...extra,
}),
});
const signIn = async (email, password) => {
const auth = await request("/api/collections/users/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: email, password }),
});
return { auth, headers: { authorization: `Bearer ${auth.token}` } };
};
const authorPassword = "WorkshopAuthor123!";
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword);
const authorSession = await signIn(author.email, authorPassword);
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
404,
);
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
const visitorSession = await signIn(visitor.email, visitorPassword);
const widget = {
$schema: "homarr-custom-widget-v2",
name: "Workshop runtime probe",
sources: {
default: {
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: "none",
},
},
requests: {},
options: {},
template: "<Text>Runtime probe</Text>",
};
const submission = await request("/api/collections/submissions/records", {
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
}),
});
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
submission.widgetSchema !== widget.$schema
) {
throw new Error("Submission publication failed");
}
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
ownerVotes.items[0].submission !== submission.id ||
ownerVotes.items[0].value !== 1
) {
throw new Error("New submissions must receive one real author-owned upvote");
}
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
});
if (updatedSubmission.title !== "Updated runtime probe")
throw new Error("Authors must be able to edit their submission");
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
});
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
}
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ author: visitor.id }) },
404,
);
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ title: "Not mine" }) },
404,
);
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
{ method: "DELETE", headers: visitorSession.headers },
404,
);
const vote = await request("/api/collections/votes/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
});
await expectStatus(
"/api/collections/votes/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
},
400,
);
await expectStatus(
`/api/collections/votes/records/${vote.id}`,
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ user: author.id }) },
404,
);
await expectStatus(
`/api/collections/votes/records/${vote.id}`,
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ value: 0 }) },
404,
);
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
if (
listing.score !== 2 ||
listing.upvotes !== 2 ||
listing.downvotes !== 0 ||
listing.authorName !== "Widget Author" ||
listing.widgetSchema !== widget.$schema
) {
throw new Error("Workshop listing data is incorrect");
}
const comment = await request("/api/collections/comments/records?expand=author", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, author: visitor.id, content: "Useful widget" }),
});
if (comment.author !== visitor.id || comment.expand.author.displayName !== "Widget Visitor") {
throw new Error("Comment author expansion is incorrect");
}
await expectStatus(
`/api/collections/comments/records/${comment.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ content: "Not mine" }) },
404,
);
const editedComment = await request(`/api/collections/comments/records/${comment.id}`, {
method: "PATCH",
headers: visitorSession.headers,
body: JSON.stringify({ content: "Useful updated widget" }),
});
if (editedComment.content !== "Useful updated widget") throw new Error("Comment editing failed");
await expectStatus(
"/api/collections/reports/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
submission: submission.id,
reporter: author.id,
category: "other",
explanation: "Spoofed reporter",
status: "open",
}),
},
400,
);
const report = await request("/api/collections/reports/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
submission: submission.id,
reporter: visitor.id,
category: "other",
explanation: "Runtime moderation test",
status: "open",
}),
});
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");
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
404,
);
const promotedAuthor = await request(`/api/collections/users/records/${author.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ isAdmin: true }),
});
if (promotedAuthor.isAdmin !== true) throw new Error("PocketBase superusers must be able to appoint admins");
await expectStatus(
`/api/collections/comments/records/${comment.id}`,
{ method: "DELETE", headers: authorSession.headers },
204,
);
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
throw new Error("Workshop administrators must be able to review reports");
}
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");
const visitorSubmission = await request("/api/collections/submissions/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Administrator deletion probe",
content: JSON.stringify(widget),
widgetSchema: widget.$schema,
author: visitor.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
}),
});
await expectStatus(
`/api/collections/submissions/records/${visitorSubmission.id}`,
{ method: "DELETE", headers: authorSession.headers },
204,
);
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
{ method: "DELETE", headers: authorSession.headers },
204,
);
console.log("Workshop PocketBase integration passed");