fix(custom-widgets): address v2 merge review

This commit is contained in:
ajnart committed 2026-07-22 23:36:25 +02:00
1 parent 6aceb2fd1f
commit 48ce83806d
122 files changed
+8534 -1447

No files matched your search

+6 -2
View File
@@ -43,6 +43,9 @@ and SMTP transport in PocketBase. PocketBase emails the submission author about
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
failures are logged and never reject a comment, report, or deletion.
GitHub OAuth settings are synchronized from the environment on every PocketBase startup. Restart the Workshop after
rotating either credential; removing one or both values disables the provider instead of retaining stale credentials.
Run the disposable service integration test from the repository root:
```sh
@@ -52,8 +55,9 @@ pnpm test:workshop
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
roles into the normal `homarr-workshop_pb_data` volume.
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
The Workshop frontend validates submissions with `customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr
validates canonical content again before installation. PocketBase independently enforces publication invariants for
direct API requests, owns schema/revision fields, and rejects stale updates using the submitted expected revision.
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
+97 -1
View File
@@ -1,5 +1,101 @@
/// <reference path="../pb_data/types.d.ts" />
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
const MAX_CSS_LENGTH = 16_384;
const MAX_CONTENT_LENGTH = 1_000_000;
const rejectRequest = (message) => {
throw new BadRequestError(message);
};
const requestBodyValue = (event, name) => {
const body = event.requestInfo().body;
return body && typeof body.get === "function" ? body.get(name) : body ? body[name] : undefined;
};
const validateWidgetManifest = (content) => {
let widget;
try {
widget = JSON.parse(content);
} catch {
rejectRequest("Widget content must be valid JSON");
}
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
rejectRequest("Widget name is invalid");
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
rejectRequest("Widget must define a default source");
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
for (const source of Object.values(widget.sources)) {
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
rejectRequest("Widget source URL is invalid");
if (!["public", "private", "loopback"].includes(source.networkScope))
rejectRequest("Widget source network scope is invalid");
}
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
rejectRequest("Widget requests are invalid");
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
rejectRequest("Widget options are invalid");
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
return JSON.stringify(widget);
};
const validateAndNormalizeSubmission = (record) => {
const type = record.getString("type");
const content = record.getString("content");
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
record.set("title", record.getString("title").trim());
record.set("description", record.getString("description").trim());
record.set("changelog", record.getString("changelog").trim());
if (type === "customCss") {
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
return;
}
if (type !== "customWidget") rejectRequest("Submission type is invalid");
record.set("content", validateWidgetManifest(content));
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
};
onBootstrap((event) => {
event.next();
const users = event.app.findCollectionByNameOrId("users");
const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
const configured = Boolean(clientId && clientSecret);
users.oauth2.enabled = configured;
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
event.app.save(users);
});
onRecordCreateRequest((event) => {
validateAndNormalizeSubmission(event.record);
event.record.set("revision", 1);
event.record.set("changelog", "");
event.record.set("outdated", false);
event.next();
}, "submissions");
onRecordUpdateRequest((event) => {
const original = event.record.original();
const currentRevision = original.getInt("revision");
const expectedRevision = Number(requestBodyValue(event, "expectedRevision"));
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== currentRevision) {
rejectRequest("Submission changed since it was read");
}
validateAndNormalizeSubmission(event.record);
event.record.set("revision", currentRevision + 1);
event.next();
}, "submissions");
onRecordCreateRequest((event) => {
event.record.set("status", "open");
event.next();
}, "reports");
onRecordAfterCreateSuccess((event) => {
try {
const votes = event.app.findCollectionByNameOrId("votes");
@@ -81,7 +177,7 @@ onRecordAfterCreateSuccess((event) => {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
if (!publicOrigin || admins.length === 0) {
event.next();
@@ -1,38 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
migrate(
(app) => {
let usersExisted = true;
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
usersExisted = false;
users = new Collection({ type: "auth", name: "users" });
}
users.passwordAuth = { enabled: false };
if (!users.fields.getByName("displayName"))
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
if (!users.fields.getByName("avatar"))
users.fields.add(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
if (!users.fields.getByName("githubUsername"))
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
const settings = app.settings();
const stateCollection = new Collection({
type: "base",
name: "workshop_migration_state",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [{ type: "text", name: "snapshot", required: true, max: 1_000_000 }],
});
app.save(stateCollection);
const state = {
usersExisted,
users: usersExisted
? {
passwordAuth: cloneJson(users.passwordAuth),
oauth2: cloneJson(users.oauth2),
listRule: users.listRule,
viewRule: users.viewRule,
createRule: users.createRule,
updateRule: users.updateRule,
deleteRule: users.deleteRule,
}
: null,
addedUserFields: [],
rateLimits: cloneJson(settings.rateLimits),
};
const addUserField = (field) => {
if (users.fields.getByName(field.name)) return;
users.fields.add(field);
state.addedUserFields.push(field.name);
};
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
}
users.passwordAuth = { enabled: false };
addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
addUserField(new URLField({ name: "avatarUrl" }));
addUserField(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
addUserField(new TextField({ name: "githubUsername", max: 100 }));
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new BoolField({ name: "isAdmin" }));
const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
@@ -41,6 +69,9 @@ migrate(
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.deleteRule = null;
app.save(users);
const stateRecord = new Record(stateCollection);
stateRecord.set("snapshot", JSON.stringify(state));
app.save(stateRecord);
const submissions = new Collection({
type: "base",
@@ -195,7 +226,6 @@ migrate(
});
app.save(listings);
const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
@@ -209,10 +239,43 @@ migrate(
app.save(settings);
},
(app) => {
let stateCollection;
let stateRecord;
try {
stateCollection = app.findCollectionByNameOrId("workshop_migration_state");
stateRecord = app.findFirstRecordByFilter(stateCollection.id, "");
} catch {
throw new Error("Workshop migration cannot be rolled back safely because its state snapshot is missing");
}
const state = JSON.parse(stateRecord.getString("snapshot"));
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {}
}
const settings = app.settings();
settings.rateLimits.enabled = state.rateLimits.enabled;
settings.rateLimits.rules = state.rateLimits.rules;
app.save(settings);
const users = app.findCollectionByNameOrId("users");
if (state.usersExisted) {
users.passwordAuth = state.users.passwordAuth;
users.oauth2 = state.users.oauth2;
users.listRule = state.users.listRule;
users.viewRule = state.users.viewRule;
users.createRule = state.users.createRule;
users.updateRule = state.users.updateRule;
users.deleteRule = state.users.deleteRule;
for (const name of state.addedUserFields) {
const field = users.fields.getByName(name);
if (field) users.fields.removeById(field.id);
}
app.save(users);
} else {
app.delete(users);
}
app.delete(stateCollection);
},
);
+28
View File
@@ -4,6 +4,9 @@ set -eu
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
@@ -30,3 +33,28 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
for client_id in workshop-test-client workshop-rotated-client; do
GITHUB_CLIENT_ID="$client_id" GITHUB_CLIENT_SECRET="workshop-test-secret" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml up -d --force-recreate workshop
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
exit 1
fi
sleep 1
done
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" EXPECTED_GITHUB_CLIENT_ID="$client_id" \
node apps/workshop/tests/oauth-sync.integration.mjs
done
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml stop workshop
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
migrate up --dir=/pb_data --migrationsDir=/pb_migrations
@@ -0,0 +1,27 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const expectedClientId = process.env.EXPECTED_GITHUB_CLIENT_ID;
if (!expectedClientId) throw new Error("EXPECTED_GITHUB_CLIENT_ID is required");
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return body;
};
const root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
});
const users = await request("/api/collections/users", {
headers: { authorization: `Bearer ${root.token}` },
});
const github = users.oauth2?.providers?.find((provider) => provider.name === "github");
if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
}
console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
@@ -0,0 +1,33 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
const read = (path) => readFile(resolve(path), "utf8");
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
const clientExport = packageJson.exports?.["./client"];
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const required of [
"workshop_migration_state",
"addedUserFields",
"state.rateLimits.enabled",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const workflow = await read(".github/workflows/workshop.yml");
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
console.log("Workshop static contracts passed");
+133
View File
@@ -0,0 +1,133 @@
import { readFile } from "node:fs/promises";
import vm from "node:vm";
// oxlint-disable-next-line typescript/no-extraneous-class -- Mirrors the PocketBase Field constructor API.
class Field {
constructor(options) {
Object.assign(this, options);
this.id = `field-${options.name}`;
}
}
class Fields {
constructor(fields = []) {
this.values = fields.map((field) => (field instanceof Field ? field : new Field(field)));
}
getByName(name) {
return this.values.find((field) => field.name === name);
}
add(field) {
this.values.push(field);
}
removeById(id) {
this.values = this.values.filter((field) => field.id !== id);
}
}
class Collection {
constructor(options) {
Object.assign(this, options);
this.id = options.id ?? options.name;
this.fields = new Fields(options.fields);
}
addIndex() {}
}
let recordId = 0;
class Record {
constructor(collection) {
this.collection = collection;
this.id = `record-${++recordId}`;
this.data = new Map();
}
set(name, value) {
this.data.set(name, value);
}
getString(name) {
return String(this.data.get(name) ?? "");
}
}
const original = {
passwordAuth: { enabled: true, identityFields: ["email"] },
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
rules: {
listRule: "original-list",
viewRule: "original-view",
createRule: "original-create",
updateRule: "original-update",
deleteRule: "original-delete",
},
rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
};
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
users.passwordAuth = structuredClone(original.passwordAuth);
users.oauth2 = structuredClone(original.oauth2);
Object.assign(users, original.rules);
const settings = { rateLimits: structuredClone(original.rateLimits) };
const collections = new Map([[users.id, users]]);
const records = new Map();
const app = {
findCollectionByNameOrId(name) {
const collection = collections.get(name);
if (!collection) throw new Error(`Missing collection ${name}`);
return collection;
},
findFirstRecordByFilter(collectionId) {
const record = records.get(collectionId)?.[0];
if (!record) throw new Error(`Missing state record ${collectionId}`);
return record;
},
save(value) {
if (value instanceof Collection) collections.set(value.id, value);
else if (value instanceof Record)
records.set(value.collection.id, [...(records.get(value.collection.id) ?? []), value]);
},
delete(collection) {
collections.delete(collection.id);
records.delete(collection.id);
},
settings() {
return settings;
},
};
let migration;
const sandbox = {
Collection,
Record,
TextField: Field,
URLField: Field,
FileField: Field,
BoolField: Field,
console,
migrate: (up, down) => {
migration = { up, down };
},
};
const source = await readFile("apps/workshop/pb_migrations/1784240000_workshop_widgets.js", "utf8");
vm.runInNewContext(source, sandbox);
if (!migration) throw new Error("Workshop migration did not register");
migration.up(app);
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
throw new Error("Workshop migration did not capture state before applying changes");
}
migration.down(app);
if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
throw new Error("Password authentication was not restored");
if (JSON.stringify(users.oauth2) !== JSON.stringify(original.oauth2))
throw new Error("OAuth settings were not restored");
for (const [name, value] of Object.entries(original.rules)) {
if (users[name] !== value) throw new Error(`User rule ${name} was not restored`);
}
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
throw new Error("Workshop user fields were not removed on rollback");
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
throw new Error("Rate limits were not restored");
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
}
console.log("Workshop migration up/down restoration passed");
+59 -13
View File
@@ -105,21 +105,54 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
widgetSchema: "homarr-custom-css-v1",
content: JSON.stringify(widget),
author: author.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
revision: 999,
changelog: "Forged publication metadata",
outdated: true,
}),
});
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
submission.widgetSchema !== widget.$schema
submission.widgetSchema !== widget.$schema ||
submission.revision !== 1 ||
submission.changelog !== "" ||
submission.outdated !== false
) {
throw new Error("Submission publication failed");
throw new Error("Submission publication was not validated and normalized server-side");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customCss",
title: "Oversized stylesheet",
content: "x".repeat(16_385),
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Invalid widget payload",
content: ".dashboard { color: red; }",
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
@@ -132,17 +165,30 @@ if (
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
body: JSON.stringify({ title: "Updated runtime probe", expectedRevision: 1, revision: 500 }),
});
if (updatedSubmission.title !== "Updated runtime probe")
if (updatedSubmission.title !== "Updated runtime probe" || updatedSubmission.revision !== 2)
throw new Error("Authors must be able to edit their submission");
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
body: JSON.stringify({ outdated: true, expectedRevision: 2, revision: 900, changelog: "Needs a newer API" }),
});
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
throw new Error("PocketBase must own submission revision increments");
}
const concurrentUpdates = await Promise.all(
["Concurrent first", "Concurrent second"].map((title) =>
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({ title, expectedRevision: 3 }),
}),
),
);
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
throw new Error("Exactly one update may consume an expected submission revision");
}
await expectStatus(
@@ -240,7 +286,7 @@ const report = await request("/api/collections/reports/records", {
reporter: visitor.id,
category: "other",
explanation: "Runtime moderation test",
status: "open",
status: "dismissed",
}),
});
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
@@ -267,7 +313,7 @@ await expectStatus(
);
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
throw new Error("Workshop administrators must be able to review reports");
}
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {