diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index 7142711c0..544654718 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -2,9 +2,11 @@ ## Correct an unavailable widget -The definition does not exist. Remove the unavailable tile from the board. +The definition does not exist. If it was deleted, remove the unavailable tile from the board. -Then, install or create a Custom JSX v2 definition. Homarr does not provide recovery or migration for old formats. +If the Custom Widgets page shows the definition with a **Migration required** badge, open its action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON from the same menu. Homarr keeps the legacy definition until the replacement validates and you confirm the migration. + +Otherwise, install or create a Custom JSX v2 definition. ## Correct an invalid saved definition @@ -12,9 +14,7 @@ The Custom Widgets page isolates a definition that does not conform to the curre An invalid definition shows its validation errors. You can delete it. -You cannot edit, enable, export, duplicate, publish, or select an invalid definition. Recreate the definition or import a valid definition. - -Homarr does not migrate old Custom JSX formats. +You cannot edit, enable, export, duplicate, publish, or select an invalid v2 definition. Recreate the definition or import a valid definition. Preserved v1 definitions use the separate **Migration required** workflow described above. ## Correct validation errors diff --git a/apps/docs/src/components/workshop/SubmitForm.tsx b/apps/docs/src/components/workshop/SubmitForm.tsx index 14995e5c9..ba4dddf98 100644 --- a/apps/docs/src/components/workshop/SubmitForm.tsx +++ b/apps/docs/src/components/workshop/SubmitForm.tsx @@ -31,7 +31,6 @@ import { cn, errorMessage } from "@/lib/utils"; import type { SubmitInput } from "./useWorkshop"; import { ScreenshotEditor } from "./ScreenshotEditor"; -import { getJsonImportSubmissionType } from "./submission-import"; interface Props { onClose: () => void; @@ -105,7 +104,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => { const reader = new FileReader(); reader.addEventListener("load", (e) => { const text = e.target?.result as string; - setType(getJsonImportSubmissionType(type)); + setType("customWidget"); setContentAndAutofill(text, title, description); if (step === 0) goTo(1); }); @@ -113,7 +112,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => { reader.readAsText(jsonFile); return true; }, - [type, step, title, description, setContentAndAutofill, goTo], + [step, title, description, setContentAndAutofill, goTo], ); const addImageFiles = useCallback( diff --git a/apps/docs/src/components/workshop/submission-import.spec.ts b/apps/docs/src/components/workshop/submission-import.spec.ts deleted file mode 100644 index 4474fa452..000000000 --- a/apps/docs/src/components/workshop/submission-import.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getJsonImportSubmissionType } from "./submission-import"; - -describe("Workshop JSON import classification", () => { - it("switches an existing Custom CSS draft to Custom Widget", () => { - expect(getJsonImportSubmissionType("customCss")).toBe("customWidget"); - }); - - it("classifies JSON before a submission type is selected", () => { - expect(getJsonImportSubmissionType(null)).toBe("customWidget"); - }); -}); diff --git a/apps/docs/src/components/workshop/submission-import.ts b/apps/docs/src/components/workshop/submission-import.ts deleted file mode 100644 index b97263421..000000000 --- a/apps/docs/src/components/workshop/submission-import.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SubmissionType } from "@site/src/lib/workshop-schema"; - -export const getJsonImportSubmissionType = (_currentType: SubmissionType | null): SubmissionType => "customWidget"; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 062b910b0..66c6f6a77 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -67,16 +67,23 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { if (!file) return; const reader = new FileReader(); reader.addEventListener("load", (loadEvent) => { - const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string); - if (!result.success) { + try { + const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string); + if (!result.success) { + showErrorNotification({ + title: t("action.migrate"), + message: formatCustomWidgetImportIssues(result.issues), + }); + return; + } + setMigratedWidget(result.widget); + migrationControls.open(); + } catch { showErrorNotification({ title: t("action.migrate"), - message: formatCustomWidgetImportIssues(result.issues), + message: t("notification.migrationError"), }); - return; } - setMigratedWidget(result.widget); - migrationControls.open(); }); reader.addEventListener("error", () => { showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") }); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.spec.ts similarity index 100% rename from apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts rename to apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.spec.ts diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts index 34b134d3b..d6040c9da 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts +++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts @@ -1,8 +1,8 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getPrivateWorkshopSourceNames, + publishWorkshopDefinition, serializeWorkshopDefinition, workshopDefinitionChanged, } from "./workshop-publish-definition"; @@ -26,16 +26,27 @@ describe("Workshop publish definition inspection", () => { expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true); }); - it("submits the same refreshed export that passed the change check", () => { - const source = readFileSync( - `${process.cwd()}/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx`, - "utf8", - ); - expect(source).toContain("customWidget.export.useQuery"); - expect(source).toContain("await definition.refetch()"); - expect(source).toContain("workshopDefinitionChanged(inspectedDefinition, refreshed.data)"); - expect(source).toContain("content: serializeWorkshopDefinition(refreshed.data)"); - expect(source).toMatch(/disabled=\{[\s\S]*!definition\.data[\s\S]*definition\.isError/u); - expect(serializeWorkshopDefinition(definition)).toBe(JSON.stringify(definition)); + it("blocks publishing when the definition changes after inspection", async () => { + const publish = vi.fn(async () => undefined); + await expect( + publishWorkshopDefinition({ + inspectedDefinition: definition, + refetchDefinition: async () => ({ ...definition, name: "Updated" }), + publish, + }), + ).resolves.toBe("changed"); + expect(publish).not.toHaveBeenCalled(); + }); + + it("publishes the same refreshed export that passed the change check", async () => { + const publish = vi.fn(async () => undefined); + await expect( + publishWorkshopDefinition({ + inspectedDefinition: definition, + refetchDefinition: async () => definition, + publish, + }), + ).resolves.toBe("published"); + expect(publish).toHaveBeenCalledWith(serializeWorkshopDefinition(definition)); }); }); diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts index 583bfff00..b3aa9c9be 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts +++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts @@ -11,6 +11,22 @@ export const serializeWorkshopDefinition = (definition: unknown): string => { export const workshopDefinitionChanged = (previous: unknown, current: unknown) => serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current); +export async function publishWorkshopDefinition({ + inspectedDefinition, + refetchDefinition, + publish, +}: { + inspectedDefinition: unknown; + refetchDefinition: () => Promise; + publish: (content: string) => Promise; +}) { + const refreshedDefinition = await refetchDefinition(); + if (refreshedDefinition === undefined) return "unavailable" as const; + if (workshopDefinitionChanged(inspectedDefinition, refreshedDefinition)) return "changed" as const; + await publish(serializeWorkshopDefinition(refreshedDefinition)); + return "published" as const; +} + export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) => Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) => source.networkScope === "public" ? [] : [source.name ?? sourceId], diff --git a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx index dd612e0b9..26d094295 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx +++ b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx @@ -12,8 +12,8 @@ import { useScopedI18n } from "@homarr/translation/client"; import { createWorkshopClient } from "./workshop-client"; import { getPrivateWorkshopSourceNames, + publishWorkshopDefinition, serializeWorkshopDefinition, - workshopDefinitionChanged, } from "./workshop-publish-definition"; export function WorkshopPublishModal({ @@ -60,22 +60,26 @@ export function WorkshopPublishModal({ try { if (!definition.data) throw new Error(t("publish.error")); const inspectedDefinition = definition.data; - const refreshed = await definition.refetch(); - if (!refreshed.data) throw new Error(t("publish.error")); - if (workshopDefinitionChanged(inspectedDefinition, refreshed.data)) { + const result = await publishWorkshopDefinition({ + inspectedDefinition, + refetchDefinition: async () => (await definition.refetch()).data, + publish: (content) => + createSubmission.mutateAsync({ + input: { + type: "customWidget", + title, + description, + content, + }, + screenshots, + }), + }); + if (result === "unavailable") throw new Error(t("publish.error")); + if (result === "changed") { setSourceUrlsReviewed(false); - setError(t("publish.error")); + setError(t("publish.definitionChanged")); return; } - await createSubmission.mutateAsync({ - input: { - type: "customWidget", - title, - description, - content: serializeWorkshopDefinition(refreshed.data), - }, - screenshots, - }); setPublished(true); } catch (cause) { setError(cause instanceof Error ? cause.message : t("publish.error")); diff --git a/apps/workshop/pb_hooks/workshop-utils.js b/apps/workshop/pb_hooks/workshop-utils.js index 849415efe..1e2f48172 100644 --- a/apps/workshop/pb_hooks/workshop-utils.js +++ b/apps/workshop/pb_hooks/workshop-utils.js @@ -7,11 +7,6 @@ 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 { @@ -87,7 +82,7 @@ const sendEmail = (app, recipientEmail, subject, text, html) => { module.exports = { emailTemplate, escapeHtml, - requestBodyValue, + rejectRequest, sendEmail, validateAndNormalizeSubmission, }; diff --git a/apps/workshop/pb_hooks/workshop.pb.js b/apps/workshop/pb_hooks/workshop.pb.js index 64abb2821..0c6e1fedf 100644 --- a/apps/workshop/pb_hooks/workshop.pb.js +++ b/apps/workshop/pb_hooks/workshop.pb.js @@ -15,20 +15,22 @@ onRecordCreateRequest((event) => { const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`); validateAndNormalizeSubmission(event.record); event.record.set("revision", 1); + event.record.set("expectedRevision", 0); event.record.set("changelog", ""); event.record.set("outdated", false); event.next(); }, "submissions"); onRecordUpdateRequest((event) => { - const { requestBodyValue, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`); + const { rejectRequest, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`); const original = event.record.original(); const currentRevision = original.getInt("revision"); - const expectedRevision = Number(requestBodyValue(event, "expectedRevision")); - if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== currentRevision) { + const expectedRevision = event.record.getInt("expectedRevision"); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { rejectRequest("Submission changed since it was read"); } validateAndNormalizeSubmission(event.record); + event.record.set("expectedRevision", expectedRevision); event.record.set("revision", currentRevision + 1); event.next(); }, "submissions"); diff --git a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js index 31e379c05..5ef2f2020 100644 --- a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js +++ b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js @@ -88,6 +88,7 @@ migrate( { type: "text", name: "widgetSchema", required: true, min: 1, max: 100 }, { type: "text", name: "content", required: true, max: 1000000 }, { type: "number", name: "revision", required: true, onlyInt: true, min: 1 }, + { type: "number", name: "expectedRevision", onlyInt: true, min: 0 }, { type: "text", name: "changelog", max: 2000 }, { type: "bool", name: "outdated" }, { @@ -112,9 +113,9 @@ migrate( `CREATE TRIGGER submissions_revision_cas BEFORE UPDATE ON submissions FOR EACH ROW - WHEN NEW.revision != OLD.revision + 1 + WHEN NEW.expectedRevision != OLD.revision OR NEW.revision != OLD.revision + 1 BEGIN - SELECT RAISE(ABORT, 'submission revision must increment exactly once'); + SELECT RAISE(ABORT, 'submission revision compare-and-swap failed'); END`, ) .execute(); @@ -285,8 +286,6 @@ migrate( if (field) users.fields.removeById(field.id); } app.save(users); - } else { - app.delete(users); } app.delete(stateCollection); }, diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 2ecd77426..284c6c022 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -9,7 +9,19 @@ 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"); -await access(resolve("apps/workshop/pb_hooks/workshop-utils.js")); +const hookUtils = await read("apps/workshop/pb_hooks/workshop-utils.js"); +const schema = await read("packages/workshop/src/schema.ts"); +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"); } @@ -25,6 +37,7 @@ for (const required of [ "workshop_migration_state", "addedUserFields", "CREATE TRIGGER submissions_revision_cas", + "NEW.expectedRevision != OLD.revision", "state.rateLimits.enabled", "users.passwordAuth = state.users.passwordAuth", "users.oauth2 = state.users.oauth2", diff --git a/apps/workshop/tests/workshop-migration.mjs b/apps/workshop/tests/workshop-migration.mjs index 424eb640a..b2881056f 100644 --- a/apps/workshop/tests/workshop-migration.mjs +++ b/apps/workshop/tests/workshop-migration.mjs @@ -125,7 +125,13 @@ 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"); } -if (!rawQueries.some((query) => query.includes("CREATE TRIGGER submissions_revision_cas"))) { +if ( + !rawQueries.some( + (query) => + query.includes("CREATE TRIGGER submissions_revision_cas") && + query.includes("NEW.expectedRevision != OLD.revision"), + ) +) { throw new Error("Workshop migration did not install the atomic submission revision guard"); } migration.down(app); @@ -145,4 +151,23 @@ for (const name of ["workshop_migration_state", "workshop_listings", "reports", if (collections.has(name)) throw new Error(`Rollback left collection ${name}`); } +collections.clear(); +records.clear(); +rawQueries.length = 0; +settings.rateLimits = structuredClone(original.rateLimits); + +migration.up(app); +const bootstrappedUsers = collections.get("users"); +if (!bootstrappedUsers) throw new Error("Fresh migration did not create the users collection"); +migration.down(app); +if (collections.get("users") !== bootstrappedUsers) { + throw new Error("Fresh rollback removed the users collection required by the bootstrap hook"); +} +for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) { + if (collections.has(name)) throw new Error(`Fresh rollback left collection ${name}`); +} +if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits)) { + throw new Error("Fresh rollback did not restore rate limits"); +} + console.log("Workshop migration up/down restoration passed"); diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts index ba8571154..44dcc998d 100644 --- a/packages/api/src/router/custom-widget/management-queries.ts +++ b/packages/api/src/router/custom-widget/management-queries.ts @@ -47,21 +47,30 @@ export const managementQueryProcedures = { return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name)); }), - legacyMigrationPrompt: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { - const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({ - where: eq(legacyCustomWidgetDefinitions.id, input.id), - with: { secrets: true }, - }); - if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" }); - return { - id: definition.id, - version: 1 as const, - prompt: buildLegacyCustomWidgetMigrationPrompt( - definition, - definition.secrets.map(({ kind }) => kind), - ), - }; - }), + legacyMigrationPrompt: manageProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get a redacted AI migration prompt for one preserved legacy Custom Widget. Credentials must be configured separately.", + }, + }) + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({ + where: eq(legacyCustomWidgetDefinitions.id, input.id), + with: { secrets: true }, + }); + if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" }); + return { + id: definition.id, + version: 1 as const, + prompt: buildLegacyCustomWidgetMigrationPrompt( + definition, + definition.secrets.map(({ kind }) => kind), + ), + }; + }), get: manageProcedure .meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } }) diff --git a/packages/api/src/router/custom-widget/transfer-procedures.ts b/packages/api/src/router/custom-widget/transfer-procedures.ts index 28655da0a..cc6f25ed2 100644 --- a/packages/api/src/router/custom-widget/transfer-procedures.ts +++ b/packages/api/src/router/custom-widget/transfer-procedures.ts @@ -40,6 +40,13 @@ export const transferProcedures = { }), migrateLegacy: manageProcedure + .meta({ + mcp: { + enabled: true, + description: + "Replace one preserved legacy Custom Widget with a validated v2 definition while retaining compatible encrypted credentials.", + }, + }) .input( z.object({ id: z.string(), @@ -124,10 +131,18 @@ export const transferProcedures = { return { id: legacy.id, preservedSecretCount: preservedSecrets.length }; }), - deleteLegacy: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { - await ctx.db.delete(legacyCustomWidgetDefinitions).where(eq(legacyCustomWidgetDefinitions.id, input.id)); - logger.info("Deleted legacy custom widget definition", { id: input.id }); - }), + deleteLegacy: manageProcedure + .meta({ + mcp: { + enabled: true, + description: "Permanently delete one preserved legacy Custom Widget that will not be migrated.", + }, + }) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + await ctx.db.delete(legacyCustomWidgetDefinitions).where(eq(legacyCustomWidgetDefinitions.id, input.id)); + logger.info("Deleted legacy custom widget definition", { id: input.id }); + }), }; function canPreserveLegacySecrets( diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 511d638cd..cf2f704fc 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -22,6 +22,7 @@ const MCP_TOOL_ALLOWLIST = { "customWidget_get", "customWidget_getAuthoringPrompt", "customWidget_getSkill", + "customWidget_legacyMigrationPrompt", "customWidget_list", "customWidget_previewJournal", "customWidget_previewQuery", @@ -69,6 +70,7 @@ const MCP_TOOL_ALLOWLIST = { "board_setHomeBoard", "board_setMobileHomeBoard", "customWidget_delete", + "customWidget_deleteLegacy", "customWidget_previewAction", "customWidget_templatePatch", "dnsHole_disable", @@ -94,6 +96,7 @@ const MCP_TOOL_ALLOWLIST = { "apiKeys_delete", "customWidget_configurationRequestUser", "customWidget_create", + "customWidget_migrateLegacy", "customWidget_previewCreate", "customWidget_secretSet", "customWidget_sourceConfigure", diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index c4aaae474..0cb381c71 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -70,7 +70,6 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise