simplify workshop submission storage

This commit is contained in:
ajnart committed 2026-07-28 22:31:58 +02:00
1 parent 6f957ee984
commit 98d56264da
22 files changed
+52 -29625

No files matched your search

-2
View File
@@ -26,8 +26,6 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: pnpm build:workshop-validator
- run: git diff --exit-code -- apps/workshop/pb_hooks/widget-validator.bundle.cjs
- run: pnpm test:workshop
- run: docker build --target production -f apps/workshop/Dockerfile .
- name: Build and smoke-test a non-root documentation deployment
@@ -75,11 +75,13 @@ Mutations remain authenticated and enforce each request's view, modify, or full
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the v1 format remain
visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the
redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. The prompt keeps safe request
metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain hard-coded
credentials. Add a credential-free description of the widget's visible layout and behavior when sending the prompt.
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. The prompt keeps
safe request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
prompt.
Homarr keeps the original v1 definition and encrypted secret as a compatibility backup even after the validated v2
replacement is created. The v2 record uses the same stable ID and takes precedence. The archived v1 copy stays hidden
+4 -3
View File
@@ -22,9 +22,10 @@ publishing, or importing Workshop content **inside a Homarr instance is restrict
:::warning
The Workshop contains community-made content. Moderation is not a security boundary. Always review widget capabilities,
network origins, actions, and CSS before installing. Homarr's installation-time validator is definitive and can reject
content that the public service accepted earlier. Back up your instance before installing untrusted content.
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
Back up your instance before installing untrusted content.
:::
-8
View File
@@ -12,10 +12,8 @@ COPY apps/docs ./apps/docs
COPY packages/custom-widgets ./packages/custom-widgets
COPY packages/definitions ./packages/definitions
COPY packages/workshop ./packages/workshop
COPY scripts/build-workshop-validator.mjs ./scripts/build-workshop-validator.mjs
COPY tooling/typescript ./tooling/typescript
RUN pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile
RUN pnpm build:workshop-validator
FROM dependencies AS website
ARG HOMARR_WEBSITE_URL=https://homarr.dev
@@ -51,9 +49,6 @@ ENTRYPOINT ["workshop-entrypoint"]
FROM pocketbase-runtime AS development
COPY --chown=pocketbase:pocketbase apps/workshop/pb_migrations /pb_migrations
COPY --chown=pocketbase:pocketbase apps/workshop/pb_hooks /pb_hooks
COPY --from=dependencies --chown=pocketbase:pocketbase \
/app/apps/workshop/pb_hooks/widget-validator.bundle.cjs \
/pb_hooks/widget-validator.bundle.cjs
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
@@ -62,9 +57,6 @@ ENV WORKSHOP_REQUIRE_PUBLIC_ORIGIN=true
COPY --from=website /app/workshop-public /pb_public
COPY apps/workshop/pb_migrations /pb_migrations
COPY apps/workshop/pb_hooks /pb_hooks
COPY --from=dependencies \
/app/apps/workshop/pb_hooks/widget-validator.bundle.cjs \
/pb_hooks/widget-validator.bundle.cjs
RUN chown -R pocketbase:pocketbase /pb_hooks /pb_migrations /pb_public
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
+4 -3
View File
@@ -93,9 +93,10 @@ separate roles in separate services.
## Validation and tests
The browser and shared client validate content with the canonical TypeScript schema. PocketBase independently validates
direct writes, owns schema and revision fields, and rejects stale compare-and-swap updates. Homarr validates the
downloaded content again before installation.
The official browser and shared client validate content with the canonical TypeScript schema and send the matching
schema identifier. PocketBase stores submission content without parsing, normalizing, or validating it; it only owns
revision fields and rejects stale compare-and-swap updates. Homarr validates downloaded content again before
installation, so direct API submissions that bypass the official client can be rejected there.
Run the disposable integration suite:
File diff suppressed because it is too large. Load diff
@@ -1,11 +0,0 @@
const { validatePocketBaseWidget } = require(`${__hooks}/widget-validator.bundle.cjs`);
const validateWidgetManifest = (content) => {
const result = validatePocketBaseWidget(content);
if (!result.success) {
throw new BadRequestError(result.error);
}
return result.content;
};
module.exports = { validateWidgetManifest };
+7 -24
View File
@@ -1,35 +1,19 @@
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
const MAX_CSS_LENGTH = 16_384;
const MAX_CONTENT_LENGTH = 1_000_000;
const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const rejectRequest = (message) => {
throw new BadRequestError(message);
};
const 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");
const { validateWidgetManifest } = require(`${__hooks}/widget-validator.js`);
record.set("content", validateWidgetManifest(content));
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
};
const normalizedIdentityText = (...values) => {
for (const value of values) {
if (typeof value !== "string") continue;
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim();
const normalized = [...value]
.filter((character) => {
const code = character.codePointAt(0) || 0;
return code > 31 && code !== 127;
})
.join("")
.trim();
if (normalized) return normalized;
}
return "";
@@ -91,5 +75,4 @@ module.exports = {
escapeHtml,
rejectRequest,
sendEmail,
validateAndNormalizeSubmission,
};
+1 -29
View File
@@ -68,20 +68,6 @@ onRecordUpdateRequest((event) => {
}, "users");
onRecordCreateRequest((event) => {
try {
const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
validateAndNormalizeSubmission(event.record);
} catch (error) {
console.log(
JSON.stringify({
event: "workshop_submission_rejected",
operation: "create",
type: event.record.getString("type"),
errorName: error && error.name ? error.name : "ValidationError",
}),
);
throw error;
}
event.record.set("revision", 1);
event.record.set("expectedRevision", 0);
event.record.set("changelog", "");
@@ -90,27 +76,13 @@ onRecordCreateRequest((event) => {
}, "submissions");
onRecordUpdateRequest((event) => {
const { rejectRequest, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
const original = event.record.original();
const currentRevision = original.getInt("revision");
const expectedRevision = event.record.getInt("expectedRevision");
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
rejectRequest("Submission changed since it was read");
}
try {
validateAndNormalizeSubmission(event.record);
} catch (error) {
console.log(
JSON.stringify({
event: "workshop_submission_rejected",
operation: "update",
submissionId: event.record.id,
type: event.record.getString("type"),
errorName: error && error.name ? error.name : "ValidationError",
}),
);
throw error;
}
event.record.set("expectedRevision", expectedRevision);
event.record.set("revision", currentRevision + 1);
event.next();
-22
View File
@@ -17,7 +17,6 @@ node apps/workshop/tests/workshop-rate-limit-migration.mjs
node apps/workshop/tests/entrypoint-url.mjs
node apps/workshop/tests/workshop-identity.mjs
node apps/workshop/tests/workshop-oauth-hook.mjs
node scripts/build-workshop-validator.mjs
cleanup() {
if [ -n "$ROLLBACK_CONTAINER" ]; then
@@ -87,27 +86,6 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
# PocketBase rate limits use the client IP as the counter key. Run the
# differential corpus in restart-separated shards so the adversarial coverage
# can grow without weakening the production write limits.
for corpus_shard in 0 1; do
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml restart 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" \
WORKSHOP_CORPUS_SHARD="$corpus_shard" WORKSHOP_CORPUS_SHARD_COUNT=2 pnpm exec tsx \
apps/workshop/tests/workshop-validator-corpus.mts
done
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
+17 -23
View File
@@ -9,25 +9,10 @@ if (typeof clientExport !== "string") throw new Error("Workshop client export is
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
const hookUtils = await read("apps/workshop/pb_hooks/workshop-utils.js");
const widgetValidator = await read("apps/workshop/pb_hooks/widget-validator.js");
const canonicalWidgetValidator = await read("packages/workshop/src/pocketbase-validator.ts");
const dockerfile = await read("apps/workshop/Dockerfile");
const schema = await read("packages/workshop/src/schema.ts");
const workshopApp = await read("apps/docs/src/components/workshop/WorkshopApp.tsx");
const workshopAdmin = await read("apps/docs/src/components/workshop/WorkshopAdmin.tsx");
const workshopDetail = await read("apps/docs/src/components/workshop/DetailPage.tsx");
const constantValue = (source, name) => {
const match = source.match(new RegExp(`(?:export )?const ${name} = ([\\d_]+)`));
if (!match?.[1]) throw new Error(`Workshop limit ${name} is missing or is not a numeric literal`);
return Number(match[1].replaceAll("_", ""));
};
if (constantValue(hookUtils, "MAX_CSS_LENGTH") !== constantValue(schema, "MAX_WORKSHOP_CSS_LENGTH")) {
throw new Error("PocketBase and shared Workshop CSS limits must match");
}
if (constantValue(hookUtils, "MAX_CONTENT_LENGTH") !== constantValue(schema, "MAX_WORKSHOP_CONTENT_LENGTH")) {
throw new Error("PocketBase and shared Workshop content limits must match");
}
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
}
@@ -51,18 +36,27 @@ if (
if (!hook.includes("workshop_report_reopened") || !hook.includes("status = 'dismissed'")) {
throw new Error("Workshop must permit a fresh report after a previous report was dismissed");
}
if (
!widgetValidator.includes("widget-validator.bundle.cjs") ||
!canonicalWidgetValidator.includes("customWidgetDefinitionSchema.safeParse")
) {
throw new Error("Workshop direct writes must use the bundled canonical Custom Widget validator");
if (hook.includes("validateAndNormalizeSubmission") || hook.includes("workshop_submission_rejected")) {
throw new Error("PocketBase must not interpret or validate Workshop submission content");
}
for (const removedValidator of [
"apps/workshop/pb_hooks/widget-validator.js",
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
"packages/workshop/src/pocketbase-validator.ts",
"scripts/build-workshop-validator.mjs",
]) {
try {
await access(resolve(removedValidator));
throw new Error(`Workshop validator artifact must stay removed: ${removedValidator}`);
} catch (error) {
if (error instanceof Error && error.message.startsWith("Workshop validator artifact")) throw error;
}
}
await access(resolve("apps/workshop/pb_hooks/widget-validator.bundle.cjs"));
if (!dockerfile.includes("WORKSHOP_REQUIRE_PUBLIC_ORIGIN=true")) {
throw new Error("The production Workshop image must require an explicit public origin");
}
if (!dockerfile.includes("COPY --from=dependencies") || !dockerfile.includes("widget-validator.bundle.cjs")) {
throw new Error("The production Workshop image must copy the freshly generated canonical validator");
if (dockerfile.includes("widget-validator") || dockerfile.includes("build-workshop-validator")) {
throw new Error("The Workshop image must not build or copy a submission validator");
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
@@ -1,335 +0,0 @@
import {
BUNDLED_CUSTOM_WIDGETS,
customWidgetDefinitionSchema,
} from "../../../packages/custom-widgets/src/core/index.ts";
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const password = "WorkshopCorpus123!";
const shard = Number(process.env.WORKSHOP_CORPUS_SHARD ?? "0");
const shardCount = Number(process.env.WORKSHOP_CORPUS_SHARD_COUNT ?? "1");
if (!Number.isInteger(shard) || !Number.isInteger(shardCount) || shard < 0 || shardCount < 1 || shard >= shardCount) {
throw new Error("Validator corpus shard configuration is invalid");
}
const email = shardCount === 1 ? "workshop-corpus@example.invalid" : `workshop-corpus-${shard}@example.invalid`;
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
const body = (await response.json().catch(() => null)) as Record<string, unknown> | null;
return { response, body };
}
const root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
});
if (!root.response.ok || typeof root.body?.token !== "string") throw new Error("Corpus superuser login failed");
const rootHeaders = { authorization: `Bearer ${root.body.token}` };
const createdUser = await request("/api/collections/users/records", {
method: "POST",
headers: rootHeaders,
body: JSON.stringify({
email,
emailVisibility: false,
verified: true,
password,
passwordConfirm: password,
displayName: "Validator Corpus",
}),
});
if (!createdUser.response.ok || typeof createdUser.body?.id !== "string") {
throw new Error(`Corpus user creation failed: ${JSON.stringify(createdUser.body)}`);
}
const userId = createdUser.body.id;
const signedIn = await request("/api/collections/users/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: email, password }),
});
if (!signedIn.response.ok || typeof signedIn.body?.token !== "string") throw new Error("Corpus user login failed");
const userHeaders = { authorization: `Bearer ${signedIn.body.token}` };
const bundled = BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({
name: `bundled:${id}`,
widget: structuredClone(widget) as Record<string, unknown>,
}));
const base = structuredClone(bundled[0]?.widget);
if (!base) throw new Error("Bundled Custom Widget corpus is empty");
const asRecord = (value: unknown) => value as Record<string, unknown>;
const withMutation = (mutate: (widget: Record<string, unknown>) => void) => {
const widget = structuredClone(base) as Record<string, unknown>;
mutate(widget);
return widget;
};
bundled.push(
{
name: "source:valid-port-zero",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://example.com:0";
}),
},
{
name: "source:valid-trailing-dot",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://example.com.";
}),
},
{
name: "source:valid-idn",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://münich.example";
}),
},
{
name: "url:valid-ipv6-and-port",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://[2001:db8::1]:65535";
widget.iconUrl = "https://[2001:db8::1]:65535/icon.svg";
}),
},
{
name: "request:valid-harmless-key-metadata",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.path = "/api/status?key=status&auth=none";
requestDefinition.headers = {
Accept: "application/json",
"X-Api-Version": "2026-07",
"X-Feature-Key": "dashboard-layout",
};
}),
},
);
const adversarial = [
{
name: "manifest:unknown-secret-field",
widget: withMutation((widget) => {
widget.secrets = [{ value: "must-never-be-published" }];
}),
},
{
name: "source:embedded-credentials",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://user:password@example.com";
}),
},
{
name: "source:invalid-numeric-host",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://1.2.3.4.5";
}),
},
{
name: "source:invalid-numeric-leading-zero",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://09.0.0.1";
}),
},
{
name: "source:ambiguous-numeric-shorthand",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://127.1";
}),
},
{
name: "source:ambiguous-hexadecimal-ipv4",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://0x7f000001";
}),
},
{
name: "source:percent-encoded-host",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://%65xample.com";
}),
},
{
name: "source:backslash-normalization",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://example.com\\@attacker.invalid";
}),
},
{
name: "source:invalid-ipv6-zone",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://[fe80::1%25eth0]";
}),
},
{
name: "source:invalid-ipv6-embedded-ipv4",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "http://[::ffff:192.168.001.001]";
}),
},
{
name: "source:invalid-a-label",
widget: withMutation((widget) => {
asRecord(asRecord(widget.sources).default).baseUrl = "https://xn--a.example";
}),
},
{
name: "request:reserved-header",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.headers = { Authorization: "must-never-be-published" };
}),
},
{
name: "request:credential-in-body",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.body = { access_token: "must-never-be-published" };
}),
},
{
name: "request:credential-in-path-query",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.path = "/api/status?credential=Bearer-sk-secret-123456";
}),
},
{
name: "request:credential-in-custom-header",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.headers = { "X-Auth": "Bearer sk-secret-123456" };
}),
},
{
name: "request:common-token-in-header-value",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.headers = { "X-Service": "ghp_abcdefghijklmnopqrstuvwxyz123456" };
}),
},
{
name: "request:automatic-delete-query",
widget: withMutation((widget) => {
const requestDefinition = asRecord(Object.values(asRecord(widget.requests))[0]);
requestDefinition.kind = "query";
requestDefinition.trigger = "load";
requestDefinition.method = "DELETE";
}),
},
{
name: "icon:credential-query",
widget: withMutation((widget) => {
widget.iconUrl = "https://example.com/icon.png?clientSecret=must-never-be-published";
}),
},
{
name: "icon:unicode-mapped-numeric-host",
widget: withMutation((widget) => {
widget.iconUrl = "https://127.0.0.1/icon.png";
}),
},
{
name: "option:invalid-default",
widget: withMutation((widget) => {
asRecord(widget.options).invalid = { label: "Invalid", control: "number", default: "not-a-number" };
}),
},
{
name: "template:incompatible-request",
widget: withMutation((widget) => {
const requestId = Object.keys(asRecord(widget.requests))[0];
widget.template = `${String(widget.template)}<ActionButton requestId="${requestId}">Unsafe</ActionButton>`;
}),
},
{
name: "template:literal-credential",
widget: withMutation((widget) => {
widget.template = "<Text>Authorization: Bearer must-never-be-published</Text>";
}),
},
{
name: "template:blocked-script",
widget: withMutation((widget) => {
widget.template = "<script>unsafe()</script>";
}),
},
{
name: "template:unknown-component",
widget: withMutation((widget) => {
widget.template = "<DefinitelyNotAWidgetComponent />";
}),
},
{
name: "template:unknown-global",
widget: withMutation((widget) => {
widget.template = "<Text>{process.env.HOME}</Text>";
}),
},
];
let updateTargetId: string | undefined;
let updateTargetRevision = 0;
for (const [index, entry] of bundled.entries()) {
const canonicalAccepted = customWidgetDefinitionSchema.safeParse(entry.widget).success;
if (!canonicalAccepted) throw new Error(`Bundled widget failed canonical validation: ${entry.name}`);
const direct = updateTargetId
? await request(`/api/collections/submissions/records/${updateTargetId}`, {
method: "PATCH",
headers: userHeaders,
body: JSON.stringify({
title: `Corpus ${String(index + 1).padStart(2, "0")}`,
description: entry.name,
content: JSON.stringify(entry.widget),
expectedRevision: updateTargetRevision,
}),
})
: await request("/api/collections/submissions/records", {
method: "POST",
headers: userHeaders,
body: JSON.stringify({
type: "customWidget",
title: `Corpus ${String(index + 1).padStart(2, "0")}`,
description: entry.name,
widgetSchema: "caller-controlled",
content: JSON.stringify(entry.widget),
author: userId,
}),
});
if (!direct.response.ok || typeof direct.body?.id !== "string") {
throw new Error(
`PocketBase rejected bundled widget ${entry.name}: status=${direct.response.status}, body=${JSON.stringify(direct.body)}`,
);
}
updateTargetId ??= direct.body.id;
if (typeof direct.body.revision !== "number") throw new Error("Corpus response did not include a numeric revision");
updateTargetRevision = direct.body.revision;
}
if (!updateTargetId) throw new Error("Differential corpus did not create an update target");
const adversarialShard = adversarial.filter((_, index) => index % shardCount === shard);
for (const entry of adversarialShard) {
if (customWidgetDefinitionSchema.safeParse(entry.widget).success) {
throw new Error(`Adversarial corpus case passed canonical validation: ${entry.name}`);
}
const direct = await request(`/api/collections/submissions/records/${updateTargetId}`, {
method: "PATCH",
headers: userHeaders,
body: JSON.stringify({
content: JSON.stringify(entry.widget),
expectedRevision: updateTargetRevision,
}),
});
if (direct.response.status === 429) {
throw new Error(`Validator corpus was rate limited before checking ${entry.name}`);
}
if (direct.response.status !== 400) {
throw new Error(
`PocketBase accepted or mishandled invalid widget ${entry.name}: status=${direct.response.status}, body=${JSON.stringify(direct.body)}`,
);
}
}
console.log(
`Workshop validator differential corpus shard ${shard + 1}/${shardCount} passed (${bundled.length + adversarialShard.length} cases)`,
);
+2 -32
View File
@@ -163,7 +163,7 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: "homarr-custom-css-v1",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
revision: 999,
@@ -179,38 +179,8 @@ if (
submission.changelog !== "" ||
submission.outdated !== false
) {
throw new Error("Submission publication was not validated and normalized server-side");
throw new Error("Submission publication metadata was not initialized server-side");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customCss",
title: "Oversized stylesheet",
content: "x".repeat(16_385),
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Invalid widget payload",
content: ".dashboard { color: red; }",
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
-1
View File
@@ -26,7 +26,6 @@
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "node ./scripts/dev-workshop.mjs",
"build:workshop-validator": "pnpm --filter @homarr/workshop build:pocketbase-validator",
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
-2
View File
@@ -18,7 +18,6 @@
"./schema": "./src/schema.ts"
},
"scripts": {
"build:pocketbase-validator": "node ../../scripts/build-workshop-validator.mjs",
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
@@ -35,7 +34,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
+2
View File
@@ -25,6 +25,7 @@ import {
WORKSHOP_API_URL,
MAX_WORKSHOP_SCREENSHOTS,
WORKSHOP_REQUEST_TIMEOUT_MS,
WORKSHOP_SCHEMA_BY_TYPE,
workshopCommentSchema,
workshopReportSchema,
workshopSubmissionDetailSchema,
@@ -437,6 +438,7 @@ export class WorkshopBackend {
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
data.set("author", this.currentUser?.id ?? "");
data.set("widgetSchema", WORKSHOP_SCHEMA_BY_TYPE[parsed.type]);
screenshots.forEach((file) => data.append("screenshots", file));
try {
const result = await this.pocketBase.collection("submissions").create(data);
+2 -2
View File
@@ -96,7 +96,7 @@ describe("WorkshopBackend", () => {
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
});
test("leaves schema and initial revision ownership to PocketBase", async () => {
test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => {
const client = new WorkshopBackend("https://workshop.example.com");
client.pocketBase.authStore.save("token", { id: "author-id" } as never);
mocks.create.mockResolvedValue({ id: "submission-id" });
@@ -113,7 +113,7 @@ describe("WorkshopBackend", () => {
const payload = mocks.create.mock.calls[0]?.[0] as FormData;
expect(payload.get("author")).toBe("author-id");
expect(payload.get("widgetSchema")).toBeNull();
expect(payload.get("widgetSchema")).toBe("homarr-custom-css-v1");
expect(payload.get("revision")).toBeNull();
});
@@ -1,35 +0,0 @@
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { installPocketBaseUrlPolyfill } from "./pocketbase-url";
installPocketBaseUrlPolyfill();
export type PocketBaseWidgetValidationResult = { success: true; content: string } | { success: false; error: string };
/**
* PocketBase loads the generated CommonJS bundle of this module in its
* embedded JSVM. Keeping the canonical schema here prevents direct Workshop
* writes from accepting definitions that Homarr would reject at install time.
*/
export function validatePocketBaseWidget(content: string): PocketBaseWidgetValidationResult {
let parsed: unknown;
try {
parsed = JSON.parse(content) as unknown;
} catch {
return { success: false, error: "Widget content must be valid JSON" };
}
const result = customWidgetDefinitionSchema.safeParse(parsed);
if (!result.success) {
return {
success: false,
error:
result.error.issues
.slice(0, 5)
.map((issue) => `${issue.path.join(".") || "widget"}: ${issue.message}`)
.join("\n") || "Invalid widget",
};
}
return { success: true, content: JSON.stringify(result.data) };
}
+1 -1
View File
@@ -74,7 +74,7 @@ describe("Workshop widget validation", () => {
).toBe(true);
});
test("uses canonical direct-binding validation before publication", () => {
test("validates direct bindings in the official client before publication", () => {
const widget = {
...CUSTOM_WIDGET_STARTER,
requests: {
+5 -1
View File
@@ -1,10 +1,14 @@
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_SCHEMA, customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
export const HOMARR_WEBSITE_URL = "https://homarr.dev";
export const WORKSHOP_API_URL = HOMARR_WEBSITE_URL;
export const WORKSHOP_WEB_URL = `${HOMARR_WEBSITE_URL}/workshop`;
export const WORKSHOP_CSS_SCHEMA = "homarr-custom-css-v1";
export const WORKSHOP_SCHEMA_BY_TYPE = {
customWidget: CUSTOM_WIDGET_SCHEMA,
customCss: WORKSHOP_CSS_SCHEMA,
} as const;
export const MAX_WORKSHOP_CSS_LENGTH = 16_384;
export const MAX_WORKSHOP_CONTENT_LENGTH = 1_000_000;
export const MAX_WORKSHOP_SCREENSHOTS = 5;
-3
View File
@@ -2962,9 +2962,6 @@ importers:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
esbuild:
specifier: 'catalog:'
version: 0.28.0
typescript:
specifier: 'catalog:'
version: '@typescript/typescript6@6.0.2'
-21
View File
@@ -1,21 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { build } from "esbuild";
const repositoryRoot = resolve(import.meta.dirname, "..");
const outfile = resolve(repositoryRoot, "apps/workshop/pb_hooks/widget-validator.bundle.cjs");
await build({
absWorkingDir: repositoryRoot,
entryPoints: [resolve(repositoryRoot, "packages/workshop/src/pocketbase-validator.ts")],
bundle: true,
platform: "neutral",
format: "cjs",
target: "es2015",
mainFields: ["module", "main"],
outfile,
});
const bundle = await readFile(outfile, "utf8");
await writeFile(outfile, bundle.replace(/[ \t]+$/gmu, ""), "utf8");