chore: snapshot workshop v2 rewrite

This commit is contained in:
Thomas Camlong committed 2026-07-14 18:42:08 +02:00
1 parent aff5a84a1d
commit 6040d09630
34 files changed
+876 -910

No files matched your search

+3 -7
View File
@@ -72,14 +72,10 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
# WORKSHOP_API_URL=http://localhost:8090
# Workshop development service and GitHub OAuth.
# The GitHub OAuth callback URL is http://localhost:8090/api/oauth2-redirect.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
PB_SUPERUSER_EMAIL=
PB_SUPERUSER_PASSWORD=
# Workshop development ports. PocketBase superusers and GitHub OAuth are
# configured once through the persistent PocketBase dashboard, not environment variables.
PB_EXPOSE_PORT=8090
PB_PUBLIC_URL=http://localhost:8090
WORKSHOP_WEB_PORT=3003
# Workshop is intentionally consumable by arbitrary self-hosted Homarr origins.
# PocketBase is stateless and authorizes writes with bearer tokens, not cookies.
PB_ALLOWED_ORIGINS=*
-53
View File
@@ -1,53 +0,0 @@
name: "[Deployment] Workshop"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "apps/workshop/**"
- "packages/definitions/src/docs/**"
- "packages/validation/**"
- "packages/workshop/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/deployment-workshop.yml"
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: workshop-${{ github.ref }}
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and publish Workshop
uses: docker/build-push-action@v7
with:
context: .
file: apps/workshop/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/homarr-labs/workshop:dev
ghcr.io/homarr-labs/workshop:sha-${{ github.sha }}
cache-from: type=gha,scope=workshop
cache-to: type=gha,mode=max,scope=workshop
+50
View File
@@ -0,0 +1,50 @@
name: Workshop
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths: &workshop-paths
- "apps/workshop/**"
- "apps/docs/docusaurus.config.ts"
- "apps/docs/package.json"
- "apps/docs/src/components/workshop/**"
- "apps/docs/src/pages/workshop/**"
- "apps/docs/src/theme/Root.tsx"
- "apps/docs/static/workshop-*"
- "apps/docs/docs/management/workshop/**"
- "packages/workshop/**"
- "packages/validation/**"
- "packages/definitions/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/workshop.yml"
push:
branches: [dev]
paths: *workshop-paths
workflow_dispatch:
permissions:
contents: read
concurrency:
group: workshop-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
production-preview:
name: Production preview
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Test Workshop backend
run: pnpm test:workshop-backend
- name: Build and smoke-test Workshop
run: pnpm test:workshop-preview
@@ -84,7 +84,7 @@ The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the wi
## Community Workshop
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. To publish your own widget, export it from Homarr and upload the JSON file in the Workshop submission form. Secrets are never included in exports and must be configured on each Homarr instance.
## Available Mantine Components for Custom JSX
+4 -2
View File
@@ -29,7 +29,7 @@ The public site also caches pages you have visited. If your network drops, a pre
## Share a creation
Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source.
Export a custom widget from Homarr, then choose or drop the JSON file in the Workshop submission form. CSS themes can be pasted into the same form. Sign in with GitHub, provide a public title and description, inspect the source, and publish it.
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
@@ -39,6 +39,8 @@ Signed-in members can vote to help useful creations rank higher. Report maliciou
## Accounts and moderation
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded.
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. PocketBase creates OAuth accounts directly; staff privileges come only from the server-managed `workshop_staff` collection. Workshop moderators can remove submissions, disable an account, and resolve reports. These actions require a reason and are recorded.
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
The service image is built directly from the Homarr repository rather than published separately. Contributors can run `pnpm dev:workshop` to start PocketBase in Docker while Docusaurus and the Workshop contracts watch on the host. Use `pnpm preview:workshop` to build the combined production image, or `pnpm reset:workshop` to explicitly clear the gitignored local PocketBase database. Superusers and GitHub OAuth are configured once in the persistent dashboard instead of being passed as environment variables.
+1 -1
View File
@@ -5,7 +5,7 @@
"license": "Apache-2.0",
"scripts": {
"docusaurus": "docusaurus",
"dev": "docusaurus start --no-open --port 3003",
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
"start": "docusaurus start --port 3003",
"build": "docusaurus build",
"serve": "docusaurus serve",
@@ -195,17 +195,6 @@ function WorkshopAdminContent() {
"Current account"
) : (
<div className={styles.rowActions}>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "posting_banned" ? "active" : "posting_banned",
})
}
>
{account.state === "posting_banned" ? "Lift posting ban" : "Posting ban"}
</button>
<button
onClick={() =>
setPending({
@@ -11,7 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
import type { Board } from "../../_types";
@@ -49,10 +48,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
</Alert>
<Group justify="space-between">
<Group>
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
</Group>
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -6,7 +6,6 @@ import {
IconCopy,
IconDots,
IconDownload,
IconShare3,
IconToggleLeft,
IconToggleRight,
IconTrash,
@@ -20,8 +19,6 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
const iconProps = { size: 16, stroke: 1.5 };
interface WidgetRef {
@@ -32,7 +29,6 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const workshopT = useScopedI18n("workshop");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -144,16 +140,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<PublishToWorkshopButton
type="widget"
defaultTitle={widget.name}
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
renderTrigger={(open) => (
<Menu.Item closeMenuOnClick={false} onClick={open} leftSection={<IconShare3 {...iconProps} />}>
{workshopT("action.share")}
</Menu.Item>
)}
/>
<Menu.Divider />
<Menu.Item
color="red"
@@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
@@ -20,12 +19,10 @@ import {
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBrandGithub,
IconBuildingStore,
IconDownload,
IconExternalLink,
@@ -355,93 +352,3 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
</Stack>
);
}
export function PublishToWorkshopButton({
type,
getContent,
defaultTitle,
compact = false,
renderTrigger,
}: {
type: WorkshopSubmissionType;
getContent: () => Promise<string> | string;
defaultTitle: string;
compact?: boolean;
renderTrigger?: (open: () => void) => ReactNode;
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [opened, { open, close }] = useDisclosure(false);
const [title, setTitle] = useState(defaultTitle);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
const publishMutation = useMutation({
mutationKey: ["workshop", "publish", type],
mutationFn: async () => {
if (!client.currentUser) await client.signInWithGitHub();
return client.create({ type, title, description, changelog, content: await getContent() });
},
onSuccess: close,
});
return (
<>
{renderTrigger ? (
renderTrigger(open)
) : (
<Button
size={compact ? "xs" : undefined}
variant="default"
leftSection={<IconBrandGithub size={16} />}
onClick={open}
>
{t("action.share")}
</Button>
)}
<Modal
opened={opened}
onClose={() => {
close();
publishMutation.reset();
}}
title={t("publish.title")}
>
<Stack>
<TextInput
required
label={t("publish.name")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
/>
<Textarea
label={t("publish.description")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
/>
{publishMutation.error && (
<Alert color="red">
{publishMutation.error instanceof Error ? publishMutation.error.message : t("error.publish")}
</Alert>
)}
<Group justify="end">
<Button variant="default" onClick={close}>
{t("action.cancel")}
</Button>
<Button
loading={publishMutation.isPending}
disabled={title.trim().length < 3}
onClick={() => publishMutation.mutate()}
>
{t("action.publish")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
+24 -5
View File
@@ -1,9 +1,12 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS website
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
ENV COREPACK_HOME=/corepack
RUN corepack enable pnpm
RUN mkdir -p /corepack && \
corepack enable pnpm && \
corepack install --global pnpm@11.10.0
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY patches ./patches
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
@@ -17,8 +20,11 @@ COPY packages/validation ./packages/validation
COPY packages/workshop ./packages/workshop
COPY tooling/typescript ./tooling/typescript
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
pnpm install --recursive --offline --frozen-lockfile && \
CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
pnpm install --recursive --offline --frozen-lockfile
FROM dependencies AS website
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM alpine:3.22 AS pocketbase
@@ -39,7 +45,20 @@ RUN case "${TARGETARCH}" in \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22
FROM alpine:3.22 AS development
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY apps/workshop/entrypoint.dev.sh /usr/local/bin/workshop-dev-entrypoint
COPY apps/workshop/dev-public /pb_public
RUN chmod +x /usr/local/bin/workshop-dev-entrypoint && \
mkdir -p /pb_data /pb_hooks /pb_migrations
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-dev-entrypoint"]
FROM alpine:3.22 AS production
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
+2
View File
@@ -6,6 +6,8 @@
!patches/**
!apps/docs/**
!apps/workshop/entrypoint.sh
!apps/workshop/entrypoint.dev.sh
!apps/workshop/dev-public/**
!apps/workshop/pb_hooks/**
!apps/workshop/pb_migrations/**
!packages/definitions/**
+64 -40
View File
@@ -1,50 +1,74 @@
# Homarr Workshop service
The Workshop image serves the compiled Homarr documentation and the PocketBase community API from one origin: `https://homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
Workshop combines the public Docusaurus experience with a PocketBase community API. Development keeps the two processes separate; the production image serves both from one origin.
## Local startup
## Development
1. Copy the repository-root `.env.example` to `.env` and create a GitHub OAuth app.
2. Set its callback URL to `http://localhost:<PB_EXPOSE_PORT>/api/oauth2-redirect` (port `8090` by default).
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
4. Run `pnpm dev:workshop` from the repository root. It builds the single Workshop image, starts it, and removes obsolete Compose containers.
Open the Workshop at `http://localhost:<PB_EXPOSE_PORT>/workshop`. The API is at `/api` on the same origin and the emergency PocketBase dashboard is at `/_/` (port `8090` by default). `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
All Workshop configuration is read from the repository-root `.env`; there is no app-specific environment file. If port 8090 is unavailable, set `PB_EXPOSE_PORT` there. `pnpm dev:workshop:website` remains available for Docusaurus HMR and connects to the local container through `WORKSHOP_API_URL`.
The multi-stage image builds Docusaurus, downloads the official PocketBase `0.39.6` release, verifies it against that release's `checksums.txt`, and copies the versioned migrations, hooks, and static website into the runtime image.
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. `PB_ALLOWED_ORIGINS` defaults to `*` because arbitrary self-hosted Homarr origins consume the central API. PocketBase authenticates writes with bearer tokens and does not use cookies; CORS is not an authorization boundary.
## Roles and account states
- `member`: publish, update/delete owned submissions, vote, and report.
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
- `admin`: moderator actions plus role changes.
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
- `disabled`: cannot authenticate or write.
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
## Backup and restore
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
From the repository root, run:
```sh
docker compose --env-file .env --file apps/workshop/docker-compose.yml stop workshop
docker run --rm -v homarr-workshop_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
docker compose --env-file .env --file apps/workshop/docker-compose.yml start workshop
pnpm dev:workshop
```
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
This starts:
## Production checklist
- PocketBase in Docker with `--dev` at `http://localhost:8090`;
- the PocketBase dashboard at `http://localhost:8090/_/`;
- `@homarr/workshop` in watch mode on the host;
- Docusaurus with HMR at `http://localhost:3003/workshop`.
- Run `ghcr.io/homarr-labs/workshop:dev` behind the `homarr.dev` TLS endpoint; the image owns both the website and `/api`.
- Terminate TLS at the reverse proxy and forward the real client IP.
- Keep superuser credentials outside Compose and rotate them after bootstrap.
- Restrict the dashboard path to operators; GitHub users never need it.
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
PocketBase data is bind-mounted at `apps/workshop/pb_data/`. The directory is gitignored and survives container rebuilds. Hooks and migrations are mounted read-only and PocketBase reloads them in development mode.
No users, superusers, OAuth providers, or submissions are seeded. Create the first superuser manually in the PocketBase dashboard, configure GitHub OAuth on the `users` collection, and use `http://localhost:8090/api/oauth2-redirect` as the local callback URL. OAuth account creation is handled by PocketBase directly. Promote moderators and administrators by creating a `workshop_staff` record for the user; that record is the authorization source of truth.
Optional ports and CORS behavior are configured only in the repository-root `.env`:
```dotenv
PB_EXPOSE_PORT=8090
WORKSHOP_WEB_PORT=3003
PB_ALLOWED_ORIGINS=*
```
## Production-like preview
Run the exact combined image shape used for deployment:
```sh
pnpm preview:workshop
```
The multi-stage image builds Docusaurus, verifies and installs PocketBase `0.39.6`, copies the hooks and migrations, and serves everything from `http://localhost:8090`:
- website: `http://localhost:8090/workshop`;
- API: `http://localhost:8090/api`;
- dashboard: `http://localhost:8090/_/`.
The preview reuses `apps/workshop/pb_data/`, so OAuth configuration and local records remain available when switching between development and preview. `--remove-orphans` replaces the previous service shape without deleting data.
## Reset local data
Resetting is always explicit:
```sh
pnpm reset:workshop
```
The command asks for confirmation, stops both service variants, and clears `apps/workshop/pb_data/`. For automation, use `pnpm reset:workshop -- --force`.
Authentication failures for an old email are requests made by the PocketBase dashboard against the persisted database; Workshop startup never attempts to authenticate or seed that account.
## Build and validation
`pnpm build` builds both `@homarr/workshop` and Docusaurus as part of the monorepo build. The production image can be validated locally with:
```sh
pnpm test:workshop-preview
```
The test uses a temporary database, builds and starts the production image, checks the website, API, service worker, and migrated collections, then removes all test state. The dedicated Workshop CI workflow runs this test only when Workshop-related sources or build inputs change; it does not publish an image.
## Operations
The live service uses the production target behind the `homarr.dev` TLS proxy. PocketBase superuser access is bootstrap/emergency access only; normal moderation belongs in `/workshop/admin`.
Back up `/pb_data` before PocketBase or schema upgrades. It contains records, uploads, OAuth settings, and credentials. Production must also restrict `/_/`, monitor health and authentication failures, and validate backup restoration regularly.
+10
View File
@@ -0,0 +1,10 @@
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))),
self.registration.unregister(),
]),
);
});
+19
View File
@@ -0,0 +1,19 @@
name: homarr-workshop
services:
workshop:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
security_opt:
- no-new-privileges:true
+4 -10
View File
@@ -5,23 +5,17 @@ services:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: development
args:
POCKETBASE_VERSION: 0.39.6
image: homarr/workshop:0.39.6
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
PB_PUBLIC_URL: ${PB_PUBLIC_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
echo "Workshop development API: http://localhost:8090/api"
echo "PocketBase dashboard: http://localhost:8090/_/"
exec pocketbase serve \
--dev \
--automigrate=false \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
+2 -6
View File
@@ -1,12 +1,8 @@
#!/bin/sh
set -eu
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
fi
echo "Workshop: ${PB_PUBLIC_URL:-http://localhost:8090}/workshop"
echo "Workshop API: ${PB_PUBLIC_URL:-http://localhost:8090}/api"
echo "Workshop is listening on port 8090"
echo "Workshop API is available at /api"
exec pocketbase serve \
--http=0.0.0.0:8090 \
+36 -8
View File
@@ -20,12 +20,10 @@ function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth, posting) {
function requireWritableAccount(app, auth) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
if (posting && account.getString("state") === "posting_banned")
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
return account;
}
@@ -78,10 +76,31 @@ function snapshot(record) {
}
function requireStaff(app, auth) {
const account = requireWritableAccount(app, auth, false);
const role = account.getString("role");
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
return role;
requireWritableAccount(app, auth);
let staff;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: auth.id });
} catch {
fail(ForbiddenError, "forbidden", "Staff access is required");
}
return staff.getString("role");
}
function setStaffRole(app, user, role) {
let staff = null;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
// Members do not have a staff record.
}
if (role === "member") {
if (staff) app.delete(staff);
return;
}
if (!staff) staff = new Record(app.findCollectionByNameOrId("workshop_staff"));
staff.set("user", user.id);
staff.set("role", role);
app.save(staff);
}
function body(e) {
@@ -104,4 +123,13 @@ function audit(app, actor, action, targetType, targetId, reason, targetSnapshot)
app.save(record);
}
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
module.exports = {
audit,
body,
fail,
requireStaff,
requireWritableAccount,
setStaffRole,
snapshot,
validateSubmission,
};
+2 -39
View File
@@ -1,42 +1,5 @@
/// <reference path="../pb_data/types.d.ts" />
onBootstrap((e) => {
e.next();
const users = e.app.findCollectionByNameOrId("users");
const clientId = $os.getenv("GITHUB_CLIENT_ID");
const clientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
users.oauth2.enabled = Boolean(clientId && clientSecret);
users.oauth2.providers = clientId && clientSecret ? [{ name: "github", clientId, clientSecret }] : [];
e.app.save(users);
});
onRecordAuthWithOAuth2Request((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record && e.record.getString("state") === "disabled")
utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
if (e.isNewRecord) {
e.createData.displayName = e.oAuth2User.name || e.oAuth2User.username || "Community member";
e.createData.avatarUrl = e.oAuth2User.avatarUrl || "";
e.createData.role = "member";
e.createData.state = "active";
}
e.next();
}, "users");
onRecordAuthRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record.getString("state") === "disabled") utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
e.next();
}, "users");
onRecordUpdateRequest((e) => {
const original = e.app.findRecordById("users", e.record.id);
e.record.set("role", original.get("role"));
e.record.set("state", original.get("state"));
e.record.set("moderationReason", original.get("moderationReason"));
e.next();
}, "users");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const account = utils.requireWritableAccount(e.app, e.auth, true);
@@ -116,8 +79,7 @@ routerAdd(
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "posting_banned", "disabled"].includes(data.state))
throw new BadRequestError("Invalid account state");
if (!["active", "disabled"].includes(data.state)) throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
@@ -149,6 +111,7 @@ routerAdd(
const previous = utils.snapshot(target);
target.set("role", data.role);
tx.save(target);
utils.setStaffRole(tx, target, data.role);
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
@@ -13,7 +13,8 @@ migrate(
}
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
users.deleteRule = null;
users.passwordAuth = { enabled: false };
// PocketBase creates a default users auth collection on first boot, so
@@ -26,20 +27,35 @@ migrate(
name: "state",
required: true,
maxSelect: 1,
values: ["active", "posting_banned", "disabled"],
values: ["active", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
users.oauth2.providers =
githubClientId && githubClientSecret
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
: [];
app.save(users);
const staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
const workshopStaff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
workshopStaff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(workshopStaff);
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
app.save(users);
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
@@ -241,7 +257,14 @@ migrate(
(app) => {
// PocketBase creates the users auth collection before this migration. Keep
// it intact on rollback so reverting Workshop can never delete accounts.
for (const name of ["workshop_listings", "moderation_actions", "reports", "votes", "submissions"]) {
for (const name of [
"workshop_listings",
"moderation_actions",
"reports",
"votes",
"submissions",
"workshop_staff",
]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
@@ -0,0 +1,73 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const state = users.fields.getByName("state");
if (state) state.values = ["active", "disabled"];
let staff;
try {
staff = app.findCollectionByNameOrId("workshop_staff");
} catch {
staff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
staff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(staff);
}
for (const user of app.findRecordsByFilter("users", "role = 'moderator' || role = 'admin'", "", 0, 0)) {
if (!user) continue;
let record;
try {
record = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
record = new Record(staff);
record.set("user", user.id);
}
record.set("role", user.getString("role"));
app.save(record);
}
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
const state = users.fields.getByName("state");
if (state) state.values = ["active", "posting_banned", "disabled"];
app.save(users);
try {
app.delete(app.findCollectionByNameOrId("workshop_staff"));
} catch {
// Already removed.
}
},
);
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
data_dir="${WORKSHOP_DATA_DIR:-apps/workshop/pb_data}"
if [ "${1:-}" != "--force" ]; then
printf "Delete the local Workshop database at %s? [y/N] " "$data_dir"
read -r answer
case "$answer" in
y|Y|yes|YES) ;;
*) echo "Reset cancelled"; exit 0 ;;
esac
fi
docker compose --env-file .env --file apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml down --remove-orphans >/dev/null 2>&1 || true
mkdir -p "$data_dir"
docker run --rm --volume "$(cd "$data_dir" && pwd):/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*'
echo "Workshop database reset"
+34 -9
View File
@@ -5,24 +5,49 @@ project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
data_dir="$(mktemp -d)"
cleanup() {
PB_EXPOSE_PORT="$port" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" \
PB_SUPERUSER_EMAIL="$email" \
PB_SUPERUSER_PASSWORD="$password" \
GITHUB_CLIENT_ID="integration-client" \
GITHUB_CLIENT_SECRET="integration-secret" \
docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" up --detach --build --wait
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --build --wait
WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/workshop/tests/workshop.integration.mjs; then
docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" logs --no-color workshop
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
restart workshop >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --wait >/dev/null
WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
WORKSHOP_VERIFY_CONFIGURATION_ONLY=true \
node apps/workshop/tests/workshop.integration.mjs
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
project="homarr-workshop-preview-test"
port="${WORKSHOP_PREVIEW_TEST_PORT:-18092}"
email="preview@homarr.test"
password="${WORKSHOP_PREVIEW_TEST_PASSWORD:-WorkshopPreviewPassword123!}"
data_dir="$(mktemp -d)"
base_url="http://127.0.0.1:$port"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
up --detach --build --wait
curl --fail --silent --show-error "$base_url/api/health" >/dev/null
curl --fail --silent --show-error --location "$base_url/workshop" >/dev/null
curl --fail --silent --show-error "$base_url/workshop-sw.js" >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data >/dev/null
token="$(curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data "{\"identity\":\"$email\",\"password\":\"$password\"}" \
"$base_url/api/collections/_superusers/auth-with-password" | jq --raw-output '.token')"
collections="$(curl --fail --silent --show-error \
--header "Authorization: $token" \
"$base_url/api/collections?perPage=200")"
for collection in users submissions votes reports moderation_actions workshop_listings; do
echo "$collections" | jq --exit-status --arg name "$collection" '.items | any(.name == $name)' >/dev/null || {
echo "Missing Workshop collection: $collection" >&2
exit 1
}
done
echo "Workshop production preview passed"
+31 -16
View File
@@ -5,18 +5,32 @@ import PocketBase, { ClientResponseError } from "pocketbase";
const baseUrl = process.env.WORKSHOP_TEST_URL;
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
const verifyConfigurationOnly = process.env.WORKSHOP_VERIFY_CONFIGURATION_ONLY === "true";
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
if (!verifyConfigurationOnly) {
await root.collections.update("users", {
oauth2: {
enabled: true,
providers: [{ name: "github", clientId: "integration-client", clientSecret: "integration-secret" }],
},
});
}
const authMethods = await root.collection("users").listAuthMethods();
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must be configured from the runtime environment");
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must retain its persisted PocketBase configuration");
assert.equal(authMethods.password.enabled, false, "password authentication must remain disabled");
assert.deepEqual(
authMethods.oauth2.providers.map((provider) => provider.name),
["github"],
);
if (verifyConfigurationOnly) {
console.log("Workshop PocketBase configuration persisted across restart");
process.exit(0);
}
const createUser = async (name, role = "member", state = "active") => {
const password = `Workshop${name}Password123!`;
return root.collection("users").create({
@@ -37,28 +51,29 @@ const expectStatus = async (operation, status) => {
const memberRecord = await createUser("Member");
const secondMemberRecord = await createUser("SecondMember");
const bannedRecord = await createUser("Banned", "member", "posting_banned");
const disabledRecord = await createUser("Disabled");
const moderatorRecord = await createUser("Moderator", "moderator");
const adminRecord = await createUser("Admin", "admin");
await root.collection("workshop_staff").create({ user: moderatorRecord.id, role: "moderator" });
await root.collection("workshop_staff").create({ user: adminRecord.id, role: "admin" });
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
const banned = await root.collection("users").impersonate(bannedRecord.id, 3600);
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
assert.equal((await member.collection("users").getList()).totalItems, 0, "members must not list Workshop accounts");
const forgedMember = await member.collection("users").update(memberRecord.id, {
role: "admin",
state: "posting_banned",
moderationReason: "self escalation",
});
assert.equal(forgedMember.role, "member", "members must not change their own role");
assert.equal(forgedMember.state, "active", "members must not change their own account state");
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
await expectStatus(
() =>
member.collection("users").update(memberRecord.id, {
role: "admin",
state: "disabled",
moderationReason: "self escalation",
}),
[403, 404],
);
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
@@ -89,10 +104,6 @@ assert.equal(submission.authorName, "Member", "server must snapshot the authenti
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-widget-v2", "server must own schemaVersion");
await expectStatus(
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
403,
);
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
@@ -127,7 +138,7 @@ await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "posting_banned", reason: "No permission" },
body: { state: "disabled", reason: "No permission" },
}),
403,
);
@@ -153,6 +164,10 @@ await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`,
body: { role: "moderator", reason: "Integration promotion" },
});
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
assert.equal(
(await root.collection("workshop_staff").getFirstListItem(`user = '${secondMemberRecord.id}'`)).role,
"moderator",
);
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
method: "POST",
+5 -2
View File
@@ -37,6 +37,9 @@ The abandoned prototype is preserved at `origin/feat/widget-store-prototype-back
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
- Deployment: the Workshop image is built locally from this repository by Compose or the Homarr deployment pipeline; no separate registry publication is maintained.
- Configuration: PocketBase superusers and GitHub OAuth are configured once in each persistent volume and are never rewritten from startup environment variables.
- Local development: PocketBase runs alone in Docker with a gitignored bind-mounted database; Docusaurus and contract watchers run on the host, and the production-like preview reuses the same data.
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
@@ -69,11 +72,11 @@ The baseline has three P0, seven P1, and one P2 findings. The rewrite closes eve
| Branch ancestry | Pass | Current `origin/dev` is an ancestor; the prototype remains preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. |
| Backend operations | Pass | PocketBase 0.39.6 builds with checksum verification; clean dev boot, persistence, disposable integration tests, and the combined production preview migrate and become healthy. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Production builds | Pass | Docusaurus, Workshop contracts, the combined PocketBase preview image, and Next.js build under Node 24.18.0; path-filtered CI smoke-tests the combined image. |
| Browser smoke | Pass | Desktop/mobile discovery, disconnected API recovery, and a first-visit online-to-offline reload render without uncaught errors. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
+7 -3
View File
@@ -21,9 +21,9 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "pnpm dev:workshop:backend",
"dev:workshop:backend": "docker compose --env-file .env -f apps/workshop/docker-compose.yml up --build --remove-orphans",
"dev:workshop:website": "pnpm -F @homarr/workshop build && pnpm -F @homarr/docs clear && pnpm with-env sh -c 'WORKSHOP_API_URL=${WORKSHOP_API_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}} pnpm -F @homarr/docs dev'",
"dev:workshop": "pnpm -F @homarr/workshop build && concurrently --kill-others-on-fail --names pocketbase,contracts,website \"pnpm dev:workshop:backend\" \"pnpm -F @homarr/workshop dev\" \"pnpm dev:workshop:website\"",
"dev:workshop:backend": "docker compose --env-file .env --file apps/workshop/docker-compose.yml up --build --remove-orphans",
"dev:workshop:website": "pnpm with-env sh -c 'WORKSHOP_API_URL=${WORKSHOP_API_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}} pnpm -F @homarr/docs docusaurus start --no-open --host 0.0.0.0 --port ${WORKSHOP_WEB_PORT:-3003}'",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
@@ -31,12 +31,15 @@
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"release": "semantic-release",
"preview:workshop": "docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml up --build --remove-orphans",
"reset:workshop": "sh apps/workshop/reset.sh",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/workshop/tests/run-integration.sh",
"test:workshop-preview": "bash apps/workshop/tests/run-preview.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
@@ -57,6 +60,7 @@
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"concurrently": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
"jsdom": "catalog:",
-10
View File
@@ -4509,25 +4509,15 @@
"loadTitle": "Workshop could not load",
"detailTitle": "This creation is unavailable",
"detailDescription": "Its source is not saved on this device and Workshop could not be reached.",
"publish": "The creation could not be published",
"install": "The creation could not be used",
"wrongType": "This creation does not match the requested type"
},
"publish": {
"title": "Share in Workshop",
"name": "Title",
"description": "Description",
"changelog": "Changelog"
},
"action": {
"retry": "Try again",
"inspect": "Inspect source",
"download": "Download",
"website": "Open website",
"use": "Use creation",
"share": "Share in Workshop",
"cancel": "Cancel",
"publish": "Publish creation",
"install": "Install widget",
"useCss": "Use CSS"
}
+1
View File
@@ -27,6 +27,7 @@
},
"scripts": {
"build": "esbuild src/index.ts src/client.ts src/schema.ts --bundle --format=esm --platform=browser --external:pocketbase --external:zod --external:zod/* --outdir=dist",
"dev": "pnpm build --watch=forever",
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
+26 -10
View File
@@ -70,12 +70,7 @@ const asWorkshopError = (error: unknown, fallback: string) => {
if (error instanceof WorkshopError) return error;
if (error instanceof ClientResponseError) {
const message = error.data?.message || error.message || fallback;
const messageCode =
message === "Account is disabled"
? "account_disabled"
: message === "Account cannot publish submissions"
? "posting_banned"
: undefined;
const messageCode = message === "Account is disabled" ? "account_disabled" : undefined;
const parsedCode = workshopErrorCodeSchema.safeParse(messageCode ?? error.data?.data?.code ?? error.data?.code);
const code = parsedCode.success ? parsedCode.data : errorCodeForStatus(error.status);
return new WorkshopError(code, message, error.status);
@@ -108,6 +103,20 @@ export class WorkshopClient {
});
}
private async currentUserWithStaffRole() {
const user = this.currentUser;
if (!user) return null;
try {
const staff = await this.pocketBase
.collection("workshop_staff")
.getFirstListItem(this.pocketBase.filter("user = {:user}", { user: user.id }));
return { ...user, role: workshopRoleSchema.parse(staff.role) };
} catch (error) {
if (error instanceof ClientResponseError && error.status === 404) return { ...user, role: "member" as const };
return user;
}
}
subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
}
@@ -116,7 +125,7 @@ export class WorkshopClient {
if (!this.pocketBase.authStore.isValid) return null;
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUser;
return this.currentUserWithStaffRole();
} catch {
this.pocketBase.authStore.clear();
return null;
@@ -133,12 +142,19 @@ export class WorkshopClient {
if (!methods.oauth2.providers.some((provider) => provider.name === "github")) {
throw new WorkshopError(
"unavailable",
"GitHub login is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, then restart Workshop.",
"GitHub login is not configured. Configure the GitHub provider once in the PocketBase users collection.",
);
}
try {
await this.pocketBase.collection("users").authWithOAuth2({ provider: "github" });
return this.currentUser;
await this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: {
displayName: "Community member",
role: "member",
state: "active",
},
});
return this.currentUserWithStaffRole();
} catch (error) {
throw asWorkshopError(error, "GitHub sign-in failed");
}
+1 -2
View File
@@ -18,7 +18,7 @@ export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema
export const workshopRoleSchema = z.enum(["member", "moderator", "admin"]);
export type WorkshopRole = z.infer<typeof workshopRoleSchema>;
export const workshopAccountStateSchema = z.enum(["active", "posting_banned", "disabled"]);
export const workshopAccountStateSchema = z.enum(["active", "disabled"]);
export type WorkshopAccountState = z.infer<typeof workshopAccountStateSchema>;
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
@@ -112,7 +112,6 @@ export type WorkshopModerationAction = z.infer<typeof workshopModerationActionSc
export const workshopErrorCodeSchema = z.enum([
"authentication_required",
"account_disabled",
"posting_banned",
"forbidden",
"not_found",
"invalid_submission",
+352 -550
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -178,7 +178,7 @@ catalog:
tsdav: ^2.3.0
tsx: 4.20.4
turbo: ^2.10.4
typescript: ^7.0.2
typescript: ^6.0.3
vite: ^8.1.2
undici: 7.28.0
use-deep-compare-effect: ^1.8.1