fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

+9 -19
View File
@@ -1,5 +1,7 @@
# syntax=docker/dockerfile:1.10
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
ENV COREPACK_HOME=/corepack
@@ -16,29 +18,17 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set
FROM dependencies AS website
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM alpine:3.22 AS pocketbase
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
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 ;; *) 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}" -o "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" -o /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22 AS development
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
FROM ${POCKETBASE_IMAGE} AS development
ENV PORT=8090
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public
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
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
ENTRYPOINT ["workshop-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
FROM ${POCKETBASE_IMAGE} AS production
ENV PORT=8090
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
COPY apps/workshop/pb_hooks /pb_hooks
@@ -46,5 +36,5 @@ COPY apps/workshop/pb_migrations /pb_migrations
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
USER pocketbase
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
ENTRYPOINT ["workshop-entrypoint"]
+34 -3
View File
@@ -2,7 +2,12 @@
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
It intentionally has no approval queue, user bans, reusable widget connections, or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report; records in `workshop_admins` can inspect reports, dismiss them, and delete any submission.
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
collections, one small user-role guard, and its explicit serve arguments.
It intentionally has no approval queue, user bans, comments, email notifications, audit log, reusable widget connections,
or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report;
users with `isAdmin` enabled can inspect and dismiss reports or delete any submission.
## Local development
@@ -10,7 +15,15 @@ It intentionally has no approval queue, user bans, reusable widget connections,
PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --build
```
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection.
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>`.
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
The `users.isAdmin` field defaults to `false` and cannot be changed by regular users. Appoint administrators from the
PocketBase dashboard with a superuser account.
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
The GitHub OAuth application needs one callback for the Workshop host:
@@ -26,6 +39,12 @@ Run the disposable service integration test from the repository root:
pnpm test:workshop
```
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.
PocketBase API rules enforce submission ownership, one vote/report per user and submission, and administrator-only
moderation. Reports are dismissed by deleting them; PocketBase cascade deletion removes related votes and reports when a
submission is deleted.
## Production docs preview
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
@@ -36,10 +55,22 @@ pnpm docker:docs
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. The preview does not mount or persist PocketBase data.
Point a local Homarr checkout at it with:
```sh
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
```
The production Homarr image reads the same variable when the container starts:
```sh
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
```
This command starts the profiled `docs` service from `apps/workshop/docker-compose.yml`. It does not start the development `workshop` service. Stop and remove the preview with:
```sh
docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --force docs
```
Production data lives in `/pb_data`. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
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`.
+10 -7
View File
@@ -6,15 +6,14 @@ services:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: development
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${PB_EXPOSE_PORT:-8090}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
- pb_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
@@ -27,11 +26,15 @@ services:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
args:
POCKETBASE_VERSION: 0.39.6
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${DOCS_EXPOSE_PORT:-3003}
ports:
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:8090"
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
volumes:
- pb_data:/pb_data
security_opt:
- no-new-privileges:true
volumes:
pb_data:
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu
exec pocketbase serve \
--http=0.0.0.0:8090 \
--http="0.0.0.0:${PORT:-8090}" \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
+6
View File
@@ -0,0 +1,6 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreate((e) => {
e.record.set("isAdmin", false);
e.next();
}, "users");
-633
View File
@@ -1,633 +0,0 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
const NETWORK_SCOPES = ["public", "private", "loopback"];
const PERMISSIONS = ["view", "modify", "full"];
const PARAMETER_TYPES = ["string", "number", "boolean"];
const TOP_LEVEL_FIELDS = [
"$schema",
"name",
"description",
"iconUrl",
"sources",
"requests",
"optionsSchema",
"defaultOptions",
"template",
];
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
const REQUEST_FIELDS = [
"id",
"sourceId",
"kind",
"method",
"pathTemplate",
"parameters",
"queryTemplate",
"bodyTemplate",
"staticHeaders",
"auth",
"minimumBoardPermission",
"trigger",
"cacheTtlSeconds",
"confirmation",
"invalidates",
];
const OPTION_FIELDS = [
"type",
"title",
"description",
"default",
"enum",
"const",
"minimum",
"maximum",
"multipleOf",
"minLength",
"maxLength",
"minItems",
"maxItems",
"format",
"items",
"properties",
"required",
"additionalProperties",
"if",
"then",
"else",
"x-homarr",
];
const OPTION_TYPES = ["object", "array", "string", "number", "integer", "boolean", "null"];
const OPTION_CONTROLS = [
"text",
"textarea",
"number",
"switch",
"select",
"multi-select",
"slider",
"date",
"time",
"color",
"icon",
"url",
"duration",
"timeZone",
"json",
];
const OPTION_FORMATS = ["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"];
const RESERVED_HEADERS = [
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
];
const SENSITIVE_KEY =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/i;
const CREDENTIAL_TEXT =
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/i;
function bad(message) {
throw new BadRequestError(message);
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validId(value) {
return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value);
}
function validBindingId(value) {
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
}
function validHttpUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s]+$/i.test(value);
}
function protectedHeader(name) {
const normalized = name.trim().toLowerCase();
return (
RESERVED_HEADERS.includes(normalized) ||
normalized.startsWith("proxy-") ||
normalized.startsWith("sec-") ||
normalized.startsWith("x-forwarded-")
);
}
function validHeaderName(name) {
return (
typeof name === "string" &&
name.length <= 256 &&
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) &&
!protectedHeader(name)
);
}
function hasControlCharacter(value) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 31 || code === 127) return true;
}
return false;
}
function validateSource(source) {
if (
!isObject(source) ||
Object.keys(source).some((key) => !SOURCE_FIELDS.includes(key)) ||
!validId(source.id) ||
typeof source.name !== "string" ||
!source.name.trim() ||
source.name.length > 128 ||
typeof source.baseUrl !== "string" ||
!NETWORK_SCOPES.includes(source.networkScope) ||
!isObject(source.auth) ||
!AUTH_TYPES.includes(source.auth.type)
)
bad("Widget contains an invalid source");
if (!/^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?$/i.test(source.baseUrl)) bad("Widget source URL is invalid");
const authority = source.baseUrl.replace(/^https?:\/\//i, "").split("/")[0];
if (authority.includes("@")) bad("Widget source URLs must not contain credentials or query values");
const authFields =
source.auth.type === "apiKeyHeader"
? ["type", "headerName"]
: source.auth.type === "apiKeyQuery"
? ["type", "parameterName"]
: ["type"];
if (Object.keys(source.auth).some((key) => !authFields.includes(key))) bad("Widget source authentication is invalid");
if (source.auth.type === "apiKeyHeader" && !validHeaderName(source.auth.headerName))
bad("API key authentication requires a safe HTTP header name");
if (
source.auth.type === "apiKeyQuery" &&
(typeof source.auth.parameterName !== "string" ||
!source.auth.parameterName ||
source.auth.parameterName.length > 256 ||
hasControlCharacter(source.auth.parameterName))
)
bad("API key query authentication requires a safe parameterName");
}
function validateRequest(request, sourceIds) {
if (
!isObject(request) ||
Object.keys(request).some((key) => !REQUEST_FIELDS.includes(key)) ||
!validId(request.id) ||
!sourceIds.has(request.sourceId) ||
!["query", "action"].includes(request.kind) ||
!METHODS.includes(request.method) ||
typeof request.pathTemplate !== "string" ||
!request.pathTemplate.startsWith("/") ||
request.pathTemplate.startsWith("//") ||
request.pathTemplate.includes("\\") ||
request.pathTemplate.includes("#") ||
!isObject(request.parameters) ||
!["inherit", "none"].includes(request.auth) ||
!PERMISSIONS.includes(request.minimumBoardPermission)
)
bad("Widget contains an invalid request");
if (
Object.keys(request.parameters).length > 32 ||
Object.entries(request.parameters).some(([name, type]) => !validBindingId(name) || !PARAMETER_TYPES.includes(type))
)
bad("Widget request parameters are invalid");
const trigger = request.trigger || "manual";
if (!["load", "manual"].includes(trigger) || (request.kind === "action" && trigger === "load"))
bad("Actions cannot run automatically");
if (request.kind === "query" && request.confirmation !== undefined) bad("Only actions can require confirmation");
if (request.kind === "query" && request.invalidates !== undefined) bad("Only actions can invalidate queries");
if (request.method === "DELETE" && request.minimumBoardPermission !== "full")
bad("DELETE requests require full permission");
if (
request.queryTemplate !== undefined &&
(!isObject(request.queryTemplate) || Object.keys(request.queryTemplate).some((key) => !key || key.length > 256))
)
bad("Widget query template is invalid");
if (
request.confirmation !== undefined &&
(!isObject(request.confirmation) ||
Object.keys(request.confirmation).some(
(key) => !["title", "message", "confirmLabel", "destructive"].includes(key),
) ||
typeof request.confirmation.title !== "string" ||
!request.confirmation.title ||
request.confirmation.title.length > 128 ||
typeof request.confirmation.message !== "string" ||
!request.confirmation.message ||
request.confirmation.message.length > 512 ||
(request.confirmation.confirmLabel !== undefined &&
(typeof request.confirmation.confirmLabel !== "string" ||
!request.confirmation.confirmLabel ||
request.confirmation.confirmLabel.length > 64)) ||
(request.confirmation.destructive !== undefined && typeof request.confirmation.destructive !== "boolean"))
)
bad("Widget action confirmation is invalid");
if (
request.invalidates !== undefined &&
(!Array.isArray(request.invalidates) ||
request.invalidates.length > 32 ||
request.invalidates.some((id) => !validId(id)))
)
bad("Widget invalidation targets are invalid");
if (
request.cacheTtlSeconds !== undefined &&
(!Number.isInteger(request.cacheTtlSeconds) ||
request.cacheTtlSeconds < 0 ||
request.cacheTtlSeconds > 3600 ||
(request.kind === "action" && request.cacheTtlSeconds !== 0))
)
bad("Widget request cache duration is invalid");
if (request.staticHeaders !== undefined) {
if (!isObject(request.staticHeaders) || Object.keys(request.staticHeaders).length > 32)
bad("Widget static headers are invalid");
for (const [name, value] of Object.entries(request.staticHeaders)) {
if (!validHeaderName(name) || typeof value !== "string" || value.length > 8192)
bad("Widget static headers are invalid");
if ((SENSITIVE_KEY.test(name) && value.trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(value))
bad("Widget static headers must not contain credentials");
}
}
const used = new Set();
const placeholderPattern = /\{([^{}]+)\}/g;
let placeholder;
while ((placeholder = placeholderPattern.exec(request.pathTemplate)) !== null) used.add(placeholder[1]);
collectParams(request.queryTemplate, used);
collectParams(request.bodyTemplate, used);
for (const name of used)
if (!Object.prototype.hasOwnProperty.call(request.parameters, name))
bad("Widget request uses an undeclared parameter");
}
function collectParams(value, result) {
if (Array.isArray(value)) {
for (const item of value) collectParams(item, result);
return;
}
if (!isObject(value)) return;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0][0] === "$param" && typeof entries[0][1] === "string") {
result.add(entries[0][1]);
return;
}
for (const entry of entries) collectParams(entry[1], result);
}
function validateOptionsMetadata(metadata, schema) {
if (
!isObject(metadata) ||
Object.keys(metadata).some((key) => !["control", "placeholder", "advanced", "order", "optionsSource"].includes(key))
)
bad("Widget option presentation metadata is invalid");
if (metadata.control !== undefined && !OPTION_CONTROLS.includes(metadata.control))
bad("Widget option control is invalid");
const controlsByType = {
string: ["text", "textarea", "select", "date", "time", "color", "icon", "url", "timeZone", "json"],
number: ["number", "select", "slider", "duration", "json"],
integer: ["number", "select", "slider", "duration", "json"],
boolean: ["switch", "select", "json"],
array: ["multi-select", "json"],
object: ["json"],
null: ["json"],
};
if (
metadata.control !== undefined &&
schema.type !== undefined &&
!controlsByType[schema.type].includes(metadata.control)
)
bad("Widget option control does not match its type");
if (metadata.placeholder !== undefined && typeof metadata.placeholder !== "string")
bad("Widget option placeholder is invalid");
if (metadata.advanced !== undefined && typeof metadata.advanced !== "boolean")
bad("Widget advanced option metadata is invalid");
if (metadata.order !== undefined && (typeof metadata.order !== "number" || !Number.isFinite(metadata.order)))
bad("Widget option order is invalid");
if (metadata.optionsSource !== undefined) {
const source = metadata.optionsSource;
if (
!isObject(source) ||
Object.keys(source).some((key) => !["requestId", "itemsPath", "valuePath", "labelPath"].includes(key)) ||
!["requestId", "valuePath", "labelPath"].every((key) => typeof source[key] === "string" && source[key]) ||
(source.itemsPath !== undefined && (typeof source.itemsPath !== "string" || !source.itemsPath))
)
bad("Widget dynamic option source is invalid");
if (!["select", "multi-select"].includes(metadata.control)) bad("Widget dynamic options require a select control");
}
}
function validateOptionsSchema(schema, root, requireType) {
if (!isObject(schema) || Object.keys(schema).some((key) => !OPTION_FIELDS.includes(key)))
bad("Widget options schema is invalid");
if (requireType !== false && !OPTION_TYPES.includes(schema.type)) bad("Every widget option needs one supported type");
if (schema.type !== undefined && !OPTION_TYPES.includes(schema.type)) bad("Widget option type is invalid");
if (root && schema.type !== "object") bad("Widget options schema root must be an object");
if (schema.type === "object" && schema.additionalProperties !== false)
bad("Widget object options must reject unknown fields");
for (const key of ["title", "description"])
if (schema[key] !== undefined && typeof schema[key] !== "string") bad("Widget option labels are invalid");
for (const key of ["minimum", "maximum", "multipleOf"])
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
bad("Widget numeric option constraints are invalid");
if (schema.multipleOf !== undefined && schema.multipleOf <= 0) bad("Widget multipleOf must be positive");
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum)
bad("Widget option bounds are invalid");
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || schema[key] < 0))
bad("Widget option size constraints are invalid");
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 100))
bad("Widget option enum is invalid");
if (schema.format !== undefined && (!OPTION_FORMATS.includes(schema.format) || schema.type !== "string"))
bad("Widget option format is invalid");
if (Array.isArray(schema.enum) && schema.enum.some((entry) => !optionMatches({ type: schema.type }, entry)))
bad("Widget option enum values do not match their type");
if (schema.const !== undefined && !optionMatches({ type: schema.type }, schema.const))
bad("Widget option constant does not match its type");
if (schema.properties !== undefined) {
if (!isObject(schema.properties)) bad("Widget option properties are invalid");
for (const [name, child] of Object.entries(schema.properties)) {
if (!validBindingId(name))
bad("Widget option names must start with a letter and contain letters, numbers, - or _");
if (SENSITIVE_KEY.test(name)) bad("Credentials must use source authentication, not widget options");
validateOptionsSchema(child, false, true);
}
if (schema.type !== undefined && schema.type !== "object") bad("Widget option properties require an object type");
if (
schema.required !== undefined &&
(!Array.isArray(schema.required) ||
schema.required.some(
(key) => typeof key !== "string" || !Object.prototype.hasOwnProperty.call(schema.properties, key),
))
)
bad("Widget required options are invalid");
}
if (schema.required !== undefined && !Array.isArray(schema.required)) bad("Widget required options are invalid");
if (schema.type === "array" && schema.items === undefined) bad("Widget array options require items");
if (schema.items !== undefined) {
if (schema.type !== undefined && schema.type !== "array") bad("Widget option items require an array type");
validateOptionsSchema(schema.items, false, true);
}
for (const branch of ["if", "then", "else"])
if (schema[branch] !== undefined) validateOptionsSchema(schema[branch], false, branch !== "if");
if (schema["x-homarr"] !== undefined) validateOptionsMetadata(schema["x-homarr"], schema);
}
function optionMatches(schema, value) {
if (schema.const !== undefined && value !== schema.const) return false;
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false;
if (schema.type === "object" && !isObject(value)) return false;
if (schema.type === "array" && !Array.isArray(value)) return false;
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value))) return false;
if (schema.type === "number" && typeof value !== "number") return false;
if (schema.type === "string" && typeof value !== "string") return false;
if (schema.type === "boolean" && typeof value !== "boolean") return false;
if (schema.type === "null" && value !== null) return false;
if (isObject(schema.properties) && isObject(value)) {
for (const [key, child] of Object.entries(schema.properties)) {
if (value[key] !== undefined && isObject(child) && !optionMatches(child, value[key])) return false;
}
}
return true;
}
function validateOptionsValue(schema, value) {
if (!optionMatches(schema, value)) bad("Widget default options do not match their schema");
if (schema.type === "object") {
if (!isObject(value)) bad("Widget default options do not match their schema");
const branch =
isObject(schema.if) && optionMatches(schema.if, value)
? schema.then
: isObject(schema.if)
? schema.else
: undefined;
const properties = Object.assign(
{},
isObject(schema.properties) ? schema.properties : {},
isObject(branch) && isObject(branch.properties) ? branch.properties : {},
);
const required = (Array.isArray(schema.required) ? schema.required : []).concat(
isObject(branch) && Array.isArray(branch.required) ? branch.required : [],
);
for (const key of required) if (value[key] === undefined) bad("Widget default options omit a required value");
for (const [key, child] of Object.entries(value)) {
if (!isObject(properties[key])) bad("Widget default options contain an unknown value");
validateOptionsValue(properties[key], child);
}
return;
}
if (schema.type === "array") {
if (!Array.isArray(value)) bad("Widget default options do not match their schema");
if (isObject(schema.items)) for (const item of value) validateOptionsValue(schema.items, item);
return;
}
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value)))
bad("Widget default options do not match their schema");
if (schema.type === "number" && typeof value !== "number") bad("Widget default options do not match their schema");
if (schema.type === "string" && typeof value !== "string") bad("Widget default options do not match their schema");
if (schema.type === "boolean" && typeof value !== "boolean") bad("Widget default options do not match their schema");
if (schema.type === "null" && value !== null) bad("Widget default options do not match their schema");
}
function collectDynamicOptionRequestIds(schema, result) {
if (!isObject(schema)) return;
const metadata = schema["x-homarr"];
if (isObject(metadata) && isObject(metadata.optionsSource)) result.add(metadata.optionsSource.requestId);
if (isObject(schema.properties))
for (const child of Object.values(schema.properties)) collectDynamicOptionRequestIds(child, result);
if (schema.items !== undefined) collectDynamicOptionRequestIds(schema.items, result);
for (const branch of ["if", "then", "else"])
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
}
function containsCredential(value, key) {
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
if (!isObject(value)) return false;
if (Object.keys(value).length === 1 && typeof value.$param === "string") return false;
return Object.entries(value).some(([childKey, child]) => containsCredential(child, childKey));
}
function containsPrototypeKey(value) {
if (Array.isArray(value)) return value.some(containsPrototypeKey);
if (!isObject(value)) return false;
return Object.entries(value).some(
([key, child]) =>
["__proto__", "prototype", "constructor"].includes(key.toLowerCase()) || containsPrototypeKey(child),
);
}
function validateTemplate(template) {
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
if (
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test(
template,
)
)
bad("Widget template requests a blocked capability");
}
function validateTemplateRequests(template, requests) {
const byId = new Map(requests.map((request) => [request.id, request]));
const componentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
let match;
while ((match = componentPattern.exec(template)) !== null) {
const idMatch = match[2].match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
const requestId = idMatch && (idMatch[1] || idMatch[2]);
if (!requestId) bad(`${match[1]} must use a literal requestId`);
const request = byId.get(requestId);
const expectedKind = match[1] === "SubFetch" ? "query" : "action";
if (!request || request.kind !== expectedKind) bad(`${match[1]} references an invalid request`);
}
}
function validateTemplateBindings(template) {
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
let match;
while ((match = bindingPattern.exec(template)) !== null) {
const inputName = match[1] || match[2];
if (!inputName) bad("bind must use a literal input name");
if (!validBindingId(inputName)) bad("bind must use a valid input name");
}
}
function validateSubmission(record) {
const content = record.getString("content");
let widget;
try {
widget = JSON.parse(content);
} catch {
bad("Widget content is not valid JSON");
}
if (
!isObject(widget) ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
!widget.name.trim() ||
widget.name.length > 128 ||
(widget.description !== undefined && (typeof widget.description !== "string" || widget.description.length > 512)) ||
(widget.iconUrl !== undefined && (!validHttpUrl(widget.iconUrl) || /^https?:\/\/[^/]*@/i.test(widget.iconUrl))) ||
Object.keys(widget).some((key) => !TOP_LEVEL_FIELDS.includes(key)) ||
!Array.isArray(widget.sources) ||
widget.sources.length < 1 ||
widget.sources.length > 8 ||
!Array.isArray(widget.requests) ||
widget.requests.length > 64 ||
!isObject(widget.defaultOptions)
)
bad("Widget does not match homarr-custom-widget-v2");
validateOptionsSchema(widget.optionsSchema, true, true);
validateOptionsValue(widget.optionsSchema, widget.defaultOptions);
if (
widget.iconUrl &&
/[?&](?:authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)=/i.test(
widget.iconUrl,
)
)
bad("Widget icon URLs must not contain credentials");
const sourceIds = new Set();
for (const source of widget.sources) {
validateSource(source);
if (sourceIds.has(source.id)) bad("Widget source IDs must be unique");
sourceIds.add(source.id);
}
const requestIds = new Set();
for (const request of widget.requests) {
validateRequest(request, sourceIds);
if (requestIds.has(request.id)) bad("Widget request IDs must be unique");
requestIds.add(request.id);
}
for (const request of widget.requests)
for (const target of request.invalidates || []) {
const targetRequest = widget.requests.find((candidate) => candidate.id === target);
if (!targetRequest || targetRequest.kind !== "query") bad("Widget invalidates an unknown or non-query request");
}
for (const request of widget.requests) {
if (request.auth !== "inherit") continue;
const source = widget.sources.find((candidate) => candidate.id === request.sourceId);
if (
source.auth.type === "apiKeyHeader" &&
Object.keys(request.staticHeaders || {}).some(
(name) => name.toLowerCase() === source.auth.headerName.toLowerCase(),
)
)
bad("Widget request overrides source authentication");
if (
source.auth.type === "apiKeyQuery" &&
Object.keys(request.queryTemplate || {}).some(
(name) => name.toLowerCase() === source.auth.parameterName.toLowerCase(),
)
)
bad("Widget request overrides source authentication");
}
const dynamicOptionRequestIds = new Set();
collectDynamicOptionRequestIds(widget.optionsSchema, dynamicOptionRequestIds);
for (const requestId of dynamicOptionRequestIds) {
const request = widget.requests.find((candidate) => candidate.id === requestId);
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
}
validateTemplate(widget.template);
validateTemplateRequests(widget.template, widget.requests);
validateTemplateBindings(widget.template);
for (const request of widget.requests) {
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
bad("Widget static headers must not contain credentials");
}
}
if (
CREDENTIAL_TEXT.test(widget.template) ||
containsCredential(widget.defaultOptions, "") ||
widget.requests.some(
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
)
)
bad("Widget exports must not contain credentials");
if (
containsPrototypeKey(widget.optionsSchema) ||
containsPrototypeKey(widget.defaultOptions) ||
widget.requests.some(
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
)
)
bad("Widget contains a prototype-pollution key");
record.set("contentHash", $security.sha256(content));
}
function isAdmin(app, userId) {
try {
app.findFirstRecordByFilter("workshop_admins", "user = {:user}", { user: userId });
return true;
} catch {
return false;
}
}
function snapshot(record) {
const data = record.publicExport();
if (typeof data.content === "string") {
data.contentHash = $security.sha256(data.content);
delete data.content;
}
return JSON.stringify(data);
}
function audit(app, actor, action, values) {
const record = new Record(app.findCollectionByNameOrId("workshop_admin_actions"));
record.set("actor", actor.id);
record.set("action", action);
Object.entries(values).forEach(([key, value]) => record.set(key, value));
app.save(record);
}
module.exports = { audit, isAdmin, snapshot, validateSubmission };
-78
View File
@@ -1,78 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (!e.auth) throw new UnauthorizedError("Authentication is required");
utils.validateSubmission(e.record);
const user = e.app.findRecordById("users", e.auth.id);
e.record.set("author", e.auth.id);
e.record.set("authorName", user.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("revision", original.getInt("revision") + 1);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordDeleteRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const adminDelete = e.auth && utils.isAdmin(e.app, e.auth.id) && e.record.getString("author") !== e.auth.id;
const snapshot = adminDelete ? utils.snapshot(e.record) : "";
e.next();
if (adminDelete)
utils.audit(e.app, e.auth, "delete_submission", {
submissionId: e.record.id,
reason: "Deleted by Workshop administrator",
snapshot,
});
}, "submissions");
onRecordCreateRequest((e) => {
if (!e.auth) throw new UnauthorizedError("Authentication is required");
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
if (!e.auth) throw new UnauthorizedError("Authentication is required");
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const original = e.app.findRecordById("reports", e.record.id);
if (e.record.getString("status") !== "dismissed") throw new BadRequestError("Reports can only be dismissed");
e.record.set("submission", original.get("submission"));
e.record.set("reporter", original.get("reporter"));
e.record.set("category", original.get("category"));
e.record.set("explanation", original.get("explanation"));
e.next();
if (original.getString("status") !== "dismissed") {
utils.audit(e.app, e.auth, "dismiss_report", {
submissionId: original.getString("submission"),
reportId: original.id,
reason: e.record.getString("dismissalReason") || "Dismissed by Workshop administrator",
snapshot: utils.snapshot(original),
});
}
}, "reports");
@@ -12,43 +12,27 @@ migrate(
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" }));
app.save(users);
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
const admins = new Collection({
type: "base",
name: "workshop_admins",
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: "autodate", name: "created", onCreate: true },
],
});
admins.addIndex("idx_workshop_admin_user", true, "user", "");
app.save(admins);
const adminRule = "@collection.workshop_admins.user ?= @request.auth.id";
const adminRule = "@request.auth.isAdmin = true";
users.listRule = adminRule;
users.viewRule = `id = @request.auth.id || ${adminRule}`;
users.updateRule = "id = @request.auth.id";
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: adminRule,
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule: "author = @request.auth.id",
updateRule: "author = @request.auth.id && @request.body.author:changed = false",
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "content", required: true, max: 1000000 },
{ type: "text", name: "contentHash", required: true, max: 64 },
{
type: "file",
name: "screenshots",
@@ -58,9 +42,6 @@ migrate(
thumbs: ["480x320", "960x640"],
},
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "text", name: "authorName", required: true, max: 100 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
@@ -74,8 +55,10 @@ migrate(
name: "votes",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "@request.auth.id != '' && @request.body.user = @request.auth.id",
updateRule: "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: [
{
@@ -102,8 +85,8 @@ migrate(
listRule: adminRule,
viewRule: `reporter = @request.auth.id || ${adminRule}`,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: adminRule,
deleteRule: null,
updateRule: null,
deleteRule: adminRule,
fields: [
{
type: "relation",
@@ -129,55 +112,29 @@ migrate(
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: "text", name: "dismissalReason", max: 1000 },
{ 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_status_created", false, "status, created", "");
reports.addIndex("idx_reports_created", false, "created", "");
app.save(reports);
const actions = new Collection({
type: "base",
name: "workshop_admin_actions",
listRule: adminRule,
viewRule: adminRule,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
{
type: "select",
name: "action",
required: true,
maxSelect: 1,
values: ["delete_submission", "dismiss_report"],
},
{ type: "text", name: "submissionId", max: 30 },
{ type: "text", name: "reportId", max: 30 },
{ type: "text", name: "reason", max: 1000 },
{ type: "text", name: "snapshot", max: 100000 },
{ type: "autodate", name: "created", onCreate: true },
],
});
actions.addIndex("idx_workshop_admin_actions_created", false, "created", "");
app.save(actions);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.title, s.description, s.contentHash, s.screenshots,
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
SELECT s.id, s.title, s.description, s.screenshots, s.author, u.displayName AS authorName,
s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
COALESCE(SUM(v.value), 0) AS score
FROM submissions s LEFT JOIN votes v ON v.submission = s.id GROUP BY s.id
FROM submissions s
JOIN users u ON u.id = s.author
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(listings);
@@ -195,14 +152,7 @@ migrate(
app.save(settings);
},
(app) => {
for (const name of [
"workshop_listings",
"workshop_admin_actions",
"reports",
"votes",
"submissions",
"workshop_admins",
]) {
for (const name of ["workshop_listings", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {}
+154 -162
View File
@@ -15,7 +15,10 @@ const expectStatus = async (path, init, expected) => {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
if (response.status !== expected) throw new Error(`Expected ${expected} for ${path}, received ${response.status}`);
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", {
@@ -25,18 +28,14 @@ const root = await request("/api/collections/_superusers/auth-with-password", {
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",
"reports",
"workshop_admins",
"workshop_admin_actions",
"workshop_listings",
]) {
for (const required of ["submissions", "votes", "reports", "workshop_listings"]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"]) {
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
}
// Workshop production authentication is GitHub OAuth. Password auth is enabled only in this disposable test database.
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
await request("/api/collections/users", {
method: "PATCH",
@@ -44,29 +43,42 @@ await request("/api/collections/users", {
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
});
const password = "WorkshopAuthor123!";
const user = await request("/api/collections/users/records", {
method: "POST",
headers: rootHeaders,
body: JSON.stringify({
email: "widget-author@example.invalid",
emailVisibility: false,
verified: true,
password,
passwordConfirm: password,
displayName: "Widget Author",
}),
});
await request("/api/collections/workshop_admins/records", {
method: "POST",
headers: rootHeaders,
body: JSON.stringify({ user: user.id }),
});
const auth = await request("/api/collections/users/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: user.email, password }),
});
const userHeaders = { authorization: `Bearer ${auth.token}` };
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, { isAdmin: true });
if (author.isAdmin !== false) throw new Error("New users must not be able to start as Workshop administrators");
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",
@@ -85,163 +97,143 @@ const widget = {
defaultOptions: {},
template: "<Text>Runtime probe</Text>",
};
const submission = await request("/api/collections/submissions/records", {
method: "POST",
headers: userHeaders,
headers: authorSession.headers,
body: JSON.stringify({
title: "Workshop runtime probe",
description: "PocketBase integration test",
content: JSON.stringify(widget),
changelog: "Initial",
author: user.id,
author: author.id,
}),
});
if (submission.revision !== 1 || submission.authorName !== "Widget Author" || submission.contentHash.length !== 64) {
throw new Error("Submission hooks did not normalize publication metadata");
if (submission.author !== author.id || submission.title !== "Workshop runtime probe") {
throw new Error("Submission publication failed");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: userHeaders,
body: JSON.stringify({
title: "Removed format",
content: JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } }),
author: user.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: userHeaders,
body: JSON.stringify({
title: "Unsafe authentication header",
content: JSON.stringify({
...widget,
sources: [{ ...widget.sources[0], auth: { type: "apiKeyHeader", headerName: "X-Forwarded-Authorization" } }],
}),
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: userHeaders,
body: JSON.stringify({
title: "Unknown template request",
content: JSON.stringify({ ...widget, template: '<SubFetch requestId="missing" />' }),
}),
},
400,
);
const updatedWidget = { ...widget, description: "Updated definition" };
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: userHeaders,
body: JSON.stringify({ content: JSON.stringify(updatedWidget), changelog: "Updated" }),
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
});
if (updatedSubmission.revision !== 2) throw new Error("Content edits must increment the submission revision");
if (updatedSubmission.title !== "Updated runtime probe") throw new Error("Authors must be able to edit their submission");
await request("/api/collections/votes/records", {
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: userHeaders,
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
});
await expectStatus(
"/api/collections/votes/records",
{
method: "POST",
headers: userHeaders,
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
},
400,
);
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0) {
throw new Error("Workshop listing vote totals are incorrect");
}
const report = await request("/api/collections/reports/records", {
method: "POST",
headers: userHeaders,
body: JSON.stringify({
submission: submission.id,
reporter: user.id,
category: "other",
explanation: "Runtime moderation test",
status: "dismissed",
}),
});
const reports = await request("/api/collections/reports/records?filter=status%3D%27open%27", {
headers: userHeaders,
});
if (reports.items.length !== 1 || reports.items[0].reporter !== user.id) {
throw new Error("Workshop administrators cannot inspect open reports and reporters");
}
await request(`/api/collections/reports/records/${report.id}`, {
method: "PATCH",
headers: userHeaders,
body: JSON.stringify({ status: "dismissed", dismissalReason: "Runtime test complete" }),
});
const actions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
if (!actions.items.some((action) => action.action === "dismiss_report" && action.reportId === report.id)) {
throw new Error("Workshop report dismissal was not audited");
}
const authorPassword = "WorkshopSecondAuthor123!";
const secondAuthor = await request("/api/collections/users/records", {
method: "POST",
headers: rootHeaders,
body: JSON.stringify({
email: "second-widget-author@example.invalid",
emailVisibility: false,
verified: true,
password: authorPassword,
passwordConfirm: authorPassword,
displayName: "Second Author",
}),
});
const secondAuth = await request("/api/collections/users/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: secondAuthor.email, password: authorPassword }),
});
const secondHeaders = { authorization: `Bearer ${secondAuth.token}` };
const secondSubmission = await request("/api/collections/submissions/records", {
method: "POST",
headers: secondHeaders,
body: JSON.stringify({
title: "Administrator deletion probe",
content: JSON.stringify(widget),
author: secondAuthor.id,
}),
});
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
{ method: "DELETE", headers: secondHeaders },
`/api/collections/votes/records/${vote.id}`,
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ user: author.id }) },
404,
);
await expectStatus(
`/api/collections/submissions/records/${secondSubmission.id}`,
{ method: "DELETE", headers: userHeaders },
204,
`/api/collections/votes/records/${vote.id}`,
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ value: 0 }) },
404,
);
const deletionActions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
if (
!deletionActions.items.some(
(action) => action.action === "delete_submission" && action.submissionId === secondSubmission.id,
)
) {
throw new Error("Workshop administrator deletion was not audited");
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0 || listing.authorName !== "Widget Author") {
throw new Error("Workshop listing data is incorrect");
}
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",
}),
},
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",
}),
});
const ownReport = await request(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers });
if (ownReport.reporter !== visitor.id) throw new Error("Reporters must be able to view their own report");
const hiddenReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
if (hiddenReports.items.length !== 0) throw new Error("Regular users must not list moderation reports");
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");
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");
}
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: authorSession.headers },
204,
);
const visitorSubmission = await request("/api/collections/submissions/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
title: "Administrator deletion probe",
content: JSON.stringify(widget),
author: visitor.id,
}),
});
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");