- A Workshop administrator removed your submission:
+ A Workshop moderator removed your submission:
{{submissionTitle}}
diff --git a/apps/workshop/pb_hooks/workshop-utils.js b/apps/workshop/pb_hooks/workshop-utils.js
index 1e2f48172..abca9ba98 100644
--- a/apps/workshop/pb_hooks/workshop-utils.js
+++ b/apps/workshop/pb_hooks/workshop-utils.js
@@ -1,56 +1,46 @@
-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 validateWidgetManifest = (content) => {
- let widget;
- try {
- widget = JSON.parse(content);
- } catch {
- rejectRequest("Widget content must be valid JSON");
+const normalizedIdentityText = (...values) => {
+ for (const value of values) {
+ if (typeof value !== "string") continue;
+ const normalized = [...value]
+ .filter((character) => {
+ const code = character.codePointAt(0) || 0;
+ return code > 31 && code !== 127;
+ })
+ .join("")
+ .trim();
+ if (normalized) return normalized;
}
- 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);
+ return "";
};
-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);
+const deriveGithubIdentity = (oauthUser, recordId) => {
+ const rawUser = oauthUser && typeof oauthUser.rawUser === "object" ? oauthUser.rawUser : {};
+ const candidateUsername = normalizedIdentityText(oauthUser && oauthUser.username, rawUser.login);
+ const githubUsername =
+ GITHUB_USERNAME_PATTERN.test(candidateUsername) && !candidateUsername.includes("--") ? candidateUsername : "";
+ const displayName =
+ normalizedIdentityText(oauthUser && oauthUser.name, rawUser.name, githubUsername, rawUser.login).slice(0, 100) ||
+ `GitHub user ${String(recordId || "").slice(0, 8)}`;
+ const candidateAvatarUrl = normalizedIdentityText(
+ oauthUser && oauthUser.avatarURL,
+ oauthUser && oauthUser.avatarUrl,
+ rawUser.avatar_url,
+ );
+ const avatarUrl =
+ candidateAvatarUrl.length <= 2_048 && /^https:\/\/[^"'\\\s]+$/i.test(candidateAvatarUrl) ? candidateAvatarUrl : "";
+
+ return {
+ displayName,
+ avatarUrl,
+ githubUsername,
+ githubProfileUrl: githubUsername ? `https://github.com/${githubUsername}` : "",
+ };
};
const escapeHtml = (value) =>
@@ -80,9 +70,9 @@ const sendEmail = (app, recipientEmail, subject, text, html) => {
};
module.exports = {
+ deriveGithubIdentity,
emailTemplate,
escapeHtml,
rejectRequest,
sendEmail,
- validateAndNormalizeSubmission,
};
diff --git a/apps/workshop/pb_hooks/workshop.pb.js b/apps/workshop/pb_hooks/workshop.pb.js
index 0c6e1fedf..b9e461ae2 100644
--- a/apps/workshop/pb_hooks/workshop.pb.js
+++ b/apps/workshop/pb_hooks/workshop.pb.js
@@ -1,19 +1,64 @@
///
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();
+ if (Boolean(clientId) !== Boolean(clientSecret)) {
+ console.log(JSON.stringify({ event: "workshop_oauth_configuration_rejected", reason: "partial_credentials" }));
+ throw new Error("GitHub OAuth requires both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET");
+ }
const configured = Boolean(clientId && clientSecret);
+ event.next();
+ const users = event.app.findCollectionByNameOrId("users");
users.oauth2.enabled = configured;
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
event.app.save(users);
+ console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
});
+onRecordAuthWithOAuth2Request((event) => {
+ if (event.providerName !== "github" || !event.oauth2User) {
+ event.next();
+ return;
+ }
+
+ const { deriveGithubIdentity } = require(`${__hooks}/workshop-utils.js`);
+ const identity = deriveGithubIdentity(event.oauth2User, event.record ? event.record.id : event.oauth2User.id);
+ if (event.record) {
+ event.record.set("displayName", identity.displayName);
+ event.record.set("avatar", "");
+ event.record.set("avatarUrl", identity.avatarUrl);
+ event.record.set("githubUsername", identity.githubUsername);
+ event.record.set("githubProfileUrl", identity.githubProfileUrl);
+ event.app.save(event.record);
+ } else {
+ event.createData.displayName = identity.displayName;
+ event.createData.avatarUrl = identity.avatarUrl;
+ event.createData.githubUsername = identity.githubUsername;
+ event.createData.githubProfileUrl = identity.githubProfileUrl;
+ }
+ console.log(
+ JSON.stringify({
+ event: "workshop_oauth_identity_synchronized",
+ provider: "github",
+ recordId: event.record ? event.record.id : null,
+ newRecord: !event.record,
+ }),
+ );
+ event.next();
+}, "users");
+
+onRecordUpdateRequest((event) => {
+ const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
+ const originalAvatar = event.record.original().getString("avatar");
+ const pendingAvatarFiles = event.record.getUnsavedFiles("avatar");
+ if (event.record.getString("avatar") !== originalAvatar || pendingAvatarFiles.length > 0) {
+ rejectRequest("Workshop avatars are managed by GitHub OAuth");
+ }
+ event.next();
+}, "users");
+
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", "");
@@ -22,20 +67,42 @@ 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");
}
- validateAndNormalizeSubmission(event.record);
event.record.set("expectedRevision", expectedRevision);
event.record.set("revision", currentRevision + 1);
event.next();
}, "submissions");
onRecordCreateRequest((event) => {
+ const auth = event.requestInfo().auth;
+ const reporter = event.record.getString("reporter");
+ const submission = event.record.getString("submission");
+ if (auth && auth.id === reporter) {
+ const dismissed = event.app.findRecordsByFilter(
+ "reports",
+ "reporter = {:reporter} && submission = {:submission} && status = 'dismissed'",
+ "",
+ 1,
+ 0,
+ { reporter, submission },
+ );
+ if (dismissed.length > 0) {
+ event.app.delete(dismissed[0]);
+ console.log(
+ JSON.stringify({
+ event: "workshop_report_reopened",
+ reporterId: reporter,
+ submissionId: submission,
+ }),
+ );
+ }
+ }
event.record.set("status", "open");
event.next();
}, "reports");
@@ -62,6 +129,7 @@ onRecordAfterCreateSuccess((event) => {
const commenter = event.app.findRecordById("users", event.record.get("author"));
const recipientEmail = recipient.email();
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
+ const publicWorkshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || `${publicOrigin}/workshop`).replace(/\/$/, "");
if (!recipientEmail || recipient.id === commenter.id || !publicOrigin) {
event.next();
@@ -71,7 +139,7 @@ onRecordAfterCreateSuccess((event) => {
const commenterName = commenter.getString("displayName") || "A community member";
const submissionTitle = submission.getString("title");
const rawExcerpt = event.record.getString("content").slice(0, 280);
- const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
+ const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
const template = emailTemplate("comment-email.html")
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
@@ -97,6 +165,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 publicWorkshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || `${publicOrigin}/workshop`).replace(/\/$/, "");
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
if (!publicOrigin || admins.length === 0) {
@@ -108,8 +177,8 @@ onRecordAfterCreateSuccess((event) => {
const submissionTitle = submission.getString("title");
const category = event.record.getString("category");
const explanation = event.record.getString("explanation");
- const adminUrl = `${publicOrigin}/workshop/admin`;
- const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
+ const adminUrl = `${publicWorkshopUrl}/admin`;
+ const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
const template = emailTemplate("report-email.html")
.replaceAll("{{reporterName}}", escapeHtml(reporterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
@@ -155,12 +224,15 @@ onRecordDeleteRequest((event) => {
if (!recipientEmail) return;
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
- const workshopUrl = publicOrigin ? `${publicOrigin}/workshop/` : "";
+ const workshopUrl = ($os.getenv("WORKSHOP_WEB_URL") || (publicOrigin ? `${publicOrigin}/workshop` : "")).replace(
+ /\/?$/,
+ "/",
+ );
const template = emailTemplate("submission-deleted-email.html").replaceAll(
"{{submissionTitle}}",
escapeHtml(submissionTitle),
);
- const text = `A Workshop administrator removed your submission “${submissionTitle}”.${
+ const text = `A Workshop moderator removed your submission “${submissionTitle}”.${
workshopUrl ? `\n\nReturn to Workshop: ${workshopUrl}` : ""
}`;
diff --git a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
index 5ef2f2020..0d8860607 100644
--- a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
+++ b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
@@ -1,6 +1,27 @@
///
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
+const cloneRule = (value) => (value === null ? null : String(value));
+const adminRule = "@request.auth.isAdmin = true";
+const identityFieldsUnchanged = [
+ "@request.body.email:changed = false",
+ "@request.body.displayName:changed = false",
+ "@request.body.avatarUrl:changed = false",
+ "@request.body.githubUsername:changed = false",
+ "@request.body.githubProfileUrl:changed = false",
+].join(" && ");
+const immutableSubmissionFields = [
+ "@request.body.author:changed = false",
+ "@request.body.type:changed = false",
+ "@request.body.widgetSchema:changed = false",
+].join(" && ");
+const moderatorSubmissionFieldsUnchanged = [
+ "@request.body.title:changed = false",
+ "@request.body.description:changed = false",
+ "@request.body.content:changed = false",
+ "@request.body.changelog:changed = false",
+ "@request.body.screenshots:changed = false",
+].join(" && ");
migrate(
(app) => {
@@ -13,7 +34,6 @@ migrate(
users = new Collection({ type: "auth", name: "users" });
}
- const settings = app.settings();
const stateCollection = new Collection({
type: "base",
name: "workshop_migration_state",
@@ -31,15 +51,14 @@ migrate(
? {
passwordAuth: cloneJson(users.passwordAuth),
oauth2: cloneJson(users.oauth2),
- listRule: users.listRule,
- viewRule: users.viewRule,
- createRule: users.createRule,
- updateRule: users.updateRule,
- deleteRule: users.deleteRule,
+ listRule: cloneRule(users.listRule),
+ viewRule: cloneRule(users.viewRule),
+ createRule: cloneRule(users.createRule),
+ updateRule: cloneRule(users.updateRule),
+ deleteRule: cloneRule(users.deleteRule),
}
: null,
addedUserFields: [],
- rateLimits: cloneJson(settings.rateLimits),
};
const addUserField = (field) => {
if (users.fields.getByName(field.name)) return;
@@ -62,11 +81,10 @@ migrate(
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new BoolField({ name: "isAdmin" }));
- const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
users.viewRule = "";
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
- users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
+ users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
users.deleteRule = null;
app.save(users);
const stateRecord = new Record(stateCollection);
@@ -79,7 +97,9 @@ migrate(
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
- updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
+ updateRule:
+ `(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
+ `(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
@@ -182,7 +202,12 @@ migrate(
listRule: adminRule,
viewRule: adminRule,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
- updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
+ updateRule:
+ `${adminRule} && ` +
+ "@request.body.reporter:changed = false && " +
+ "@request.body.submission:changed = false && " +
+ "@request.body.category:changed = false && " +
+ "@request.body.explanation:changed = false",
deleteRule: adminRule,
fields: [
{
@@ -238,18 +263,6 @@ migrate(
`,
});
app.save(listings);
-
- settings.rateLimits.enabled = true;
- settings.rateLimits.rules = [
- ...settings.rateLimits.rules.filter(
- (rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
- ),
- { label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
- { label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
- { label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
- { label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
- ];
- app.save(settings);
},
(app) => {
let stateCollection;
@@ -267,11 +280,6 @@ migrate(
} 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;
@@ -286,6 +294,8 @@ migrate(
if (field) users.fields.removeById(field.id);
}
app.save(users);
+ } else {
+ app.delete(users);
}
app.delete(stateCollection);
},
diff --git a/apps/workshop/test.sh b/apps/workshop/test.sh
index f2ab27a1a..b010a45ca 100644
--- a/apps/workshop/test.sh
+++ b/apps/workshop/test.sh
@@ -6,6 +6,7 @@ WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
+node apps/workshop/tests/workshop-identity.mjs
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs
index 284c6c022..7219fe970 100644
--- a/apps/workshop/tests/workshop-contracts.mjs
+++ b/apps/workshop/tests/workshop-contracts.mjs
@@ -9,25 +9,30 @@ 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 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");
}
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("validateAndNormalizeSubmission")) {
+ throw new Error("PocketBase must store Workshop submissions without interpreting their content");
+}
+for (const artifact 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",
+]) {
+ await access(resolve(artifact))
+ .then(() => {
+ throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
+ })
+ .catch((error) => {
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
+ throw error;
+ });
+}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
@@ -36,11 +41,12 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi
for (const required of [
"workshop_migration_state",
"addedUserFields",
+ "cloneRule(users.listRule)",
"CREATE TRIGGER submissions_revision_cas",
"NEW.expectedRevision != OLD.revision",
- "state.rateLimits.enabled",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
+ "app.delete(users)",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
diff --git a/apps/workshop/tests/workshop-identity.mjs b/apps/workshop/tests/workshop-identity.mjs
new file mode 100644
index 000000000..ecd72e296
--- /dev/null
+++ b/apps/workshop/tests/workshop-identity.mjs
@@ -0,0 +1,63 @@
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+const { deriveGithubIdentity } = require("../pb_hooks/workshop-utils.js");
+
+const identity = deriveGithubIdentity(
+ {
+ name: " Octo Cat ",
+ username: "octocat",
+ avatarURL: "https://avatars.githubusercontent.com/u/1?v=4",
+ rawUser: {},
+ },
+ "record123456",
+);
+if (
+ identity.displayName !== "Octo Cat" ||
+ identity.githubUsername !== "octocat" ||
+ identity.githubProfileUrl !== "https://github.com/octocat" ||
+ identity.avatarUrl !== "https://avatars.githubusercontent.com/u/1?v=4"
+) {
+ throw new Error(`Trusted GitHub identity was not normalized: ${JSON.stringify(identity)}`);
+}
+
+const rawFallback = deriveGithubIdentity(
+ {
+ name: "",
+ username: "",
+ avatarURL: "",
+ rawUser: {
+ name: "\nRaw User\u0000",
+ login: "raw-user",
+ avatar_url: "https://avatars.githubusercontent.com/u/2",
+ },
+ },
+ "record123456",
+);
+if (
+ rawFallback.displayName !== "Raw User" ||
+ rawFallback.githubUsername !== "raw-user" ||
+ rawFallback.githubProfileUrl !== "https://github.com/raw-user"
+) {
+ throw new Error(`Raw GitHub identity fallback was not normalized: ${JSON.stringify(rawFallback)}`);
+}
+
+const rejected = deriveGithubIdentity(
+ {
+ name: "\n",
+ username: "not/a/github-user",
+ avatarURL: "javascript:alert(1)",
+ rawUser: {},
+ },
+ "record123456",
+);
+if (
+ rejected.displayName !== "GitHub user record12" ||
+ rejected.githubUsername !== "" ||
+ rejected.githubProfileUrl !== "" ||
+ rejected.avatarUrl !== ""
+) {
+ throw new Error(`Invalid provider identity fields were retained: ${JSON.stringify(rejected)}`);
+}
+
+console.log("Workshop OAuth identity normalization passed");
diff --git a/apps/workshop/tests/workshop-migration.mjs b/apps/workshop/tests/workshop-migration.mjs
index b2881056f..97cad695d 100644
--- a/apps/workshop/tests/workshop-migration.mjs
+++ b/apps/workshop/tests/workshop-migration.mjs
@@ -50,7 +50,7 @@ class Record {
const original = {
passwordAuth: { enabled: true, identityFields: ["email"] },
- oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
+ oauth2: { enabled: true, providers: [{ name: "original", clientId: "before", clientSecret: "before-secret" }] },
rules: {
listRule: "original-list",
viewRule: "original-view",
@@ -58,13 +58,11 @@ const original = {
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 rawQueries = [];
@@ -99,9 +97,6 @@ const app = {
collections.delete(collection.id);
records.delete(collection.id);
},
- settings() {
- return settings;
- },
};
let migration;
@@ -122,7 +117,7 @@ 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) {
+if (!collections.has("workshop_migration_state")) {
throw new Error("Workshop migration did not capture state before applying changes");
}
if (
@@ -145,8 +140,6 @@ for (const [name, value] of Object.entries(original.rules)) {
}
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}`);
}
@@ -154,20 +147,20 @@ for (const name of ["workshop_migration_state", "workshop_listings", "reports",
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");
+if (!collections.has("users")) 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"]) {
+for (const name of [
+ "users",
+ "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/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs
index 2bb173552..be597e96a 100644
--- a/apps/workshop/tests/workshop.integration.mjs
+++ b/apps/workshop/tests/workshop.integration.mjs
@@ -77,6 +77,55 @@ await expectStatus(
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
404,
);
+for (const [field, value] of [
+ ["displayName", "Homarr Team"],
+ ["avatarUrl", "https://example.invalid/forged-avatar.png"],
+ ["githubUsername", "homarr-labs"],
+ ["githubProfileUrl", "https://github.com/homarr-labs"],
+]) {
+ await expectStatus(
+ `/api/collections/users/records/${author.id}`,
+ { method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ [field]: value }) },
+ 404,
+ );
+}
+const forgedAvatar = new FormData();
+forgedAvatar.set(
+ "avatar",
+ new Blob(
+ [
+ Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
+ "base64",
+ ),
+ ],
+ { type: "image/png" },
+ ),
+ "forged-avatar.png",
+);
+const avatarResponse = await fetch(`${baseUrl}/api/collections/users/records/${author.id}`, {
+ method: "PATCH",
+ headers: authorSession.headers,
+ body: forgedAvatar,
+});
+const avatarResponseBody = await avatarResponse.json().catch(() => null);
+if (avatarResponse.status !== 400 || !JSON.stringify(avatarResponseBody).includes("managed by GitHub OAuth")) {
+ throw new Error(
+ `Workshop user avatar upload bypassed the OAuth identity hook: ${avatarResponse.status} ${JSON.stringify(avatarResponseBody)}`,
+ );
+}
+const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
+ headers: authorSession.headers,
+});
+if (
+ unchangedAuthor.displayName !== "Widget Author" ||
+ unchangedAuthor.avatar ||
+ unchangedAuthor.avatarUrl ||
+ unchangedAuthor.githubUsername ||
+ unchangedAuthor.githubProfileUrl
+) {
+ throw new Error("Workshop users can forge OAuth provider identity fields");
+}
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
@@ -105,7 +154,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,
@@ -121,38 +170,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 ||
@@ -295,7 +314,7 @@ const report = await request("/api/collections/reports/records", {
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
-if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop administrators");
+if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop moderators");
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
@@ -317,14 +336,45 @@ await expectStatus(
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
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");
+ throw new Error("Workshop moderators must be able to review reports");
}
+await expectStatus(
+ `/api/collections/reports/records/${report.id}`,
+ {
+ method: "PATCH",
+ headers: authorSession.headers,
+ body: JSON.stringify({ category: "spam", explanation: "Moderator-authored replacement" }),
+ },
+ 404,
+);
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ status: "dismissed" }),
});
-if (dismissedReport.status !== "dismissed") throw new Error("Workshop administrators must be able to dismiss reports");
+if (dismissedReport.status !== "dismissed") throw new Error("Workshop moderators must be able to dismiss reports");
+const reopenedReport = await request("/api/collections/reports/records", {
+ method: "POST",
+ headers: visitorSession.headers,
+ body: JSON.stringify({
+ submission: submission.id,
+ reporter: visitor.id,
+ category: "malicious",
+ explanation: "The later submission revision now needs another review",
+ status: "dismissed",
+ }),
+});
+if (reopenedReport.status !== "open" || reopenedReport.id === report.id) {
+ throw new Error("A dismissed Workshop report must be replaceable with a fresh open report");
+}
+const reopenedReports = await request("/api/collections/reports/records", { headers: authorSession.headers });
+if (
+ reopenedReports.items.length !== 1 ||
+ reopenedReports.items[0].id !== reopenedReport.id ||
+ reopenedReports.items[0].status !== "open"
+) {
+ throw new Error("Re-reporting must replace only the reporter's dismissed report");
+}
const visitorSubmission = await request("/api/collections/submissions/records", {
method: "POST",
@@ -340,6 +390,27 @@ const visitorSubmission = await request("/api/collections/submissions/records",
outdated: false,
}),
});
+await expectStatus(
+ `/api/collections/submissions/records/${visitorSubmission.id}`,
+ {
+ method: "PATCH",
+ headers: authorSession.headers,
+ body: JSON.stringify({
+ title: "Moderator-authored replacement",
+ content: JSON.stringify({ ...widget, name: "Moderator-authored replacement" }),
+ expectedRevision: 1,
+ }),
+ },
+ 404,
+);
+const moderatedStatus = await request(`/api/collections/submissions/records/${visitorSubmission.id}`, {
+ method: "PATCH",
+ headers: authorSession.headers,
+ body: JSON.stringify({ outdated: true, expectedRevision: 1 }),
+});
+if (!moderatedStatus.outdated || moderatedStatus.revision !== 2) {
+ throw new Error("Workshop moderators must be limited to CAS-protected submission status changes");
+}
await expectStatus(
`/api/collections/submissions/records/${visitorSubmission.id}`,
{ method: "DELETE", headers: authorSession.headers },
diff --git a/packages/api/src/custom-widget-configuration.ts b/packages/api/src/custom-widget-configuration.ts
index ab155cd39..7e527dc99 100644
--- a/packages/api/src/custom-widget-configuration.ts
+++ b/packages/api/src/custom-widget-configuration.ts
@@ -4,3 +4,4 @@ export {
parseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
+export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts
index a978acf01..07a4d56ea 100644
--- a/packages/api/src/env.ts
+++ b/packages/api/src/env.ts
@@ -1,16 +1,34 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
+import { normalizeHttpUrl } from "@homarr/workshop/schema";
+
+const publicHttpUrl = (variableName: string) =>
+ z.string().transform((value, context) => {
+ try {
+ return normalizeHttpUrl(value, variableName);
+ } catch (error) {
+ context.addIssue({
+ code: "custom",
+ message: error instanceof Error ? error.message : `${variableName} is invalid`,
+ });
+ return z.NEVER;
+ }
+ });
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
+ HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
+ WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
+ HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
+ WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
},
});
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 9cb34d1bd..95bc7af6d 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -61,6 +61,10 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
+import {
+ throwIfCustomWidgetBoardDuplicationForbidden,
+ throwIfCustomWidgetPlacementChangeForbidden,
+} from "./board/custom-widget-placement-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
export const boardRouter = createTRPCRouter({
@@ -372,6 +376,7 @@ export const boardRouter = createTRPCRouter({
}
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
+ throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
const newBoardId = createId();
@@ -855,6 +860,11 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: ctx.session.user.permissions.includes("admin"),
+ submittedItems: input.items,
+ storedItems: dbBoard.items,
+ });
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -1440,6 +1450,11 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: ctx.session.user.permissions.includes("admin"),
+ submittedItems: [{ id: "", kind: input.kind, options: input.options }],
+ storedItems: [],
+ });
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
diff --git a/packages/api/src/router/board/custom-widget-placement-access.ts b/packages/api/src/router/board/custom-widget-placement-access.ts
new file mode 100644
index 000000000..fdb8d6de5
--- /dev/null
+++ b/packages/api/src/router/board/custom-widget-placement-access.ts
@@ -0,0 +1,85 @@
+import { isDeepStrictEqual } from "node:util";
+
+import { TRPCError } from "@trpc/server";
+
+interface BoardPlacement {
+ id: string;
+ kind: string;
+ options: Record;
+}
+
+interface CustomWidgetPlacementGuardInput {
+ isAdmin: boolean;
+ submittedItems: readonly BoardPlacement[];
+ storedItems: readonly BoardPlacement[];
+}
+
+const throwAdminRequired = () => {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only administrators can add or configure Custom Widgets",
+ });
+};
+
+const assertAuthoringAllowed = (isAdmin: boolean) => {
+ if (!isAdmin) throwAdminRequired();
+};
+
+/**
+ * Protects the security-sensitive part of Custom Widget placements while
+ * leaving ordinary board operations (layout, presentation and removal) to the
+ * existing board permission checks.
+ */
+export const throwIfCustomWidgetPlacementChangeForbidden = ({
+ isAdmin,
+ submittedItems,
+ storedItems,
+}: CustomWidgetPlacementGuardInput) => {
+ const storedById = new Map(storedItems.map((item) => [item.id, item]));
+ for (const submitted of submittedItems) {
+ const stored = storedById.get(submitted.id);
+
+ if (!stored) {
+ if (submitted.kind === "customApi") assertAuthoringAllowed(isAdmin);
+ continue;
+ }
+
+ const wasCustomWidget = stored.kind === "customApi";
+ const isCustomWidget = submitted.kind === "customApi";
+ if (wasCustomWidget !== isCustomWidget) {
+ if (isCustomWidget) assertAuthoringAllowed(isAdmin);
+ else if (!isAdmin) throwAdminRequired();
+ continue;
+ }
+ if (
+ wasCustomWidget &&
+ !isDeepStrictEqual(normalizeCustomWidgetOptions(submitted.options), normalizeCustomWidgetOptions(stored.options))
+ ) {
+ assertAuthoringAllowed(isAdmin);
+ }
+ }
+};
+
+export const throwIfCustomWidgetBoardDuplicationForbidden = (
+ isAdmin: boolean,
+ items: readonly Pick[],
+) => {
+ if (items.some((item) => item.kind === "customApi")) assertAuthoringAllowed(isAdmin);
+};
+
+function normalizeCustomWidgetOptions(options: Record) {
+ return {
+ ...options,
+ configuration:
+ options.configuration !== null &&
+ typeof options.configuration === "object" &&
+ !Array.isArray(options.configuration)
+ ? options.configuration
+ : {},
+ configurationVersion:
+ typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
+ ? options.configurationVersion
+ : 1,
+ refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : 30,
+ };
+}
diff --git a/packages/api/src/router/board/test/custom-widget-placement-access.spec.ts b/packages/api/src/router/board/test/custom-widget-placement-access.spec.ts
new file mode 100644
index 000000000..141d95248
--- /dev/null
+++ b/packages/api/src/router/board/test/custom-widget-placement-access.spec.ts
@@ -0,0 +1,109 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ throwIfCustomWidgetBoardDuplicationForbidden,
+ throwIfCustomWidgetPlacementChangeForbidden,
+} from "../custom-widget-placement-access";
+
+const placement = {
+ id: "widget-1",
+ kind: "customApi",
+ options: {
+ definitionId: "definition-1",
+ refreshInterval: 30,
+ configuration: { server: "primary" },
+ },
+};
+
+describe("Custom Widget placement access", () => {
+ test("non-admins cannot add a Custom Widget placement", () => {
+ expect(() =>
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: false,
+ submittedItems: [{ ...placement, options: { ...placement.options, configurationVersion: 1 } }],
+ storedItems: [],
+ }),
+ ).toThrowError("Only administrators can add or configure Custom Widgets");
+ });
+
+ test.each([
+ ["definition", { ...placement, options: { ...placement.options, definitionId: "definition-2" } }],
+ ["refresh interval", { ...placement, options: { ...placement.options, refreshInterval: 60 } }],
+ [
+ "option configuration",
+ { ...placement, options: { ...placement.options, configuration: { server: "secondary" } } },
+ ],
+ ["widget kind", { ...placement, kind: "weather" }],
+ ])("non-admins cannot change an existing Custom Widget %s", (_label, submitted) => {
+ expect(() =>
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: false,
+ submittedItems: [submitted],
+ storedItems: [placement],
+ }),
+ ).toThrowError("Only administrators can add or configure Custom Widgets");
+ });
+
+ test("non-admins may move, resize and change presentation for an existing placement", () => {
+ const submitted = {
+ ...placement,
+ layouts: [{ xOffset: 2, yOffset: 4, width: 6, height: 3 }],
+ advancedOptions: { title: "Status", customCssClasses: ["wide"], borderColor: "#fff" },
+ };
+ expect(() =>
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: false,
+ submittedItems: [submitted],
+ storedItems: [placement],
+ }),
+ ).not.toThrow();
+ });
+
+ test("non-admins may remove an existing placement", () => {
+ expect(() =>
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: false,
+ submittedItems: [],
+ storedItems: [placement],
+ }),
+ ).not.toThrow();
+ });
+
+ test("admins may create and reconfigure placements", () => {
+ expect(() =>
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: true,
+ submittedItems: [{ ...placement, options: { definitionId: "definition-2" } }],
+ storedItems: [],
+ }),
+ ).not.toThrow();
+ });
+
+ test("duplicating a board with Custom Widgets requires admin", () => {
+ expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [placement])).toThrowError(
+ "Only administrators can add or configure Custom Widgets",
+ );
+ expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, [placement])).not.toThrow();
+ expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [{ kind: "weather" }])).not.toThrow();
+ });
+
+ test("treats missing persisted v2 defaults as equivalent during presentation-only edits", () => {
+ expect(() =>
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: false,
+ submittedItems: [placement],
+ storedItems: [
+ {
+ id: placement.id,
+ kind: placement.kind,
+ options: {
+ definitionId: placement.options.definitionId,
+ refreshInterval: placement.options.refreshInterval,
+ configuration: placement.options.configuration,
+ },
+ },
+ ],
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts
index e39cc5bc1..83c6bb080 100644
--- a/packages/api/src/router/custom-widget/custom-widget-router.ts
+++ b/packages/api/src/router/custom-widget/custom-widget-router.ts
@@ -30,24 +30,18 @@ import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const customWidgetRouter = createTRPCRouter({
...metadataProcedures,
...managementQueryProcedures,
- create: manageProcedure
+ create: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
- if (secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Writing custom widget secrets requires dedicated permission",
- });
- }
const definition = customWidgetDefinitionSchema.parse(candidate);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
@@ -55,7 +49,8 @@ export const customWidgetRouter = createTRPCRouter({
return { id };
}),
- update: manageProcedure
+ update: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
@@ -66,12 +61,6 @@ export const customWidgetRouter = createTRPCRouter({
const current = parseStoredCustomWidgetDefinition(existing);
const { id, secrets, ...changes } = input;
- if (secrets?.length && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Writing custom widget secrets requires dedicated permission",
- });
- }
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
@@ -194,7 +183,8 @@ export const customWidgetRouter = createTRPCRouter({
...secretProcedures,
...workshopProcedures,
- toggleEnabled: manageProcedure
+ toggleEnabled: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (input.enabled) {
@@ -216,7 +206,8 @@ export const customWidgetRouter = createTRPCRouter({
.where(eq(customWidgetDefinitions.id, input.id));
}),
- delete: manageProcedure
+ delete: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
@@ -224,20 +215,23 @@ export const customWidgetRouter = createTRPCRouter({
logger.info("Deleted custom widget definition", { id: input.id });
}),
- duplicate: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
- const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
- where: eq(customWidgetDefinitions.id, input.id),
- });
- if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
- const current = parseStoredCustomWidgetDefinition(existing);
- const id = createId();
- await ctx.db.insert(customWidgetDefinitions).values({
- id,
- ...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
- creatorId: ctx.session.user.id,
- });
- return { id, name: `${current.name} (copy)` };
- }),
+ duplicate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, input.id),
+ });
+ if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
+ const current = parseStoredCustomWidgetDefinition(existing);
+ const id = createId();
+ await ctx.db.insert(customWidgetDefinitions).values({
+ id,
+ ...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
+ creatorId: ctx.session.user.id,
+ });
+ return { id, name: `${current.name} (copy)` };
+ }),
...templateProcedures,
...transferProcedures,
diff --git a/packages/api/src/router/custom-widget/legacy-migration.ts b/packages/api/src/router/custom-widget/legacy-migration.ts
index 48d7c70a2..a3726d182 100644
--- a/packages/api/src/router/custom-widget/legacy-migration.ts
+++ b/packages/api/src/router/custom-widget/legacy-migration.ts
@@ -1,5 +1,3 @@
-import { parse as parseSuperJson } from "superjson";
-
import type { legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
@@ -13,14 +11,17 @@ export function buildLegacyCustomWidgetMigrationPrompt(
$schema: "homarr-custom-widget-v1",
name: definition.name,
description: definition.description,
- iconUrl: definition.iconUrl,
+ iconUrl: definition.iconUrl ? redactLegacyUrl(definition.iconUrl) : null,
url: redactLegacyUrl(definition.url),
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: redactLegacyRequestBody(definition.requestBody),
displayType: definition.displayType,
- displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
+ displayConfig: {
+ migrationNote:
+ "The preserved v1 display configuration was intentionally omitted because legacy free-text fields may contain credentials. Reconstruct it from the display type and visible behavior.",
+ },
configuredSecretKinds,
};
return buildCustomWidgetAiPrompt(
@@ -69,11 +70,3 @@ function redactAllValues(value: unknown): unknown {
if (value === null) return null;
return "[REDACTED VALUE]";
}
-
-function parseLegacyDisplayConfig(value: string): unknown {
- try {
- return parseSuperJson(value) as unknown;
- } catch {
- return { migrationNote: "The stored v1 display configuration could not be decoded.", raw: value.slice(0, 4_000) };
- }
-}
diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts
index 44dcc998d..ef4022e7d 100644
--- a/packages/api/src/router/custom-widget/management-queries.ts
+++ b/packages/api/src/router/custom-widget/management-queries.ts
@@ -6,7 +6,7 @@ import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
-import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
+import { permissionRequiredProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
@@ -26,10 +26,9 @@ import {
mapLegacyCustomWidgetListItem,
} from "./management-query-mappers";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-
export const managementQueryProcedures = {
- list: manageProcedure
+ list: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
const [definitions, legacyDefinitions] = await Promise.all([
@@ -43,11 +42,13 @@ export const managementQueryProcedures = {
}),
]);
const current = definitions.map(mapCustomWidgetListItem);
- const legacy = legacyDefinitions.map(mapLegacyCustomWidgetListItem);
+ const currentIds = new Set(definitions.map(({ id }) => id));
+ const legacy = legacyDefinitions.filter(({ id }) => !currentIds.has(id)).map(mapLegacyCustomWidgetListItem);
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
}),
- legacyMigrationPrompt: manageProcedure
+ legacyMigrationPrompt: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -72,7 +73,8 @@ export const managementQueryProcedures = {
};
}),
- get: manageProcedure
+ get: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
@@ -96,7 +98,8 @@ export const managementQueryProcedures = {
};
}),
- available: protectedProcedure
+ available: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
@@ -115,7 +118,8 @@ export const managementQueryProcedures = {
return [...available, mapLegacyAvailableCustomWidget(legacy)];
}),
- optionRequest: protectedProcedure
+ optionRequest: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(
z.object({
boardId: z.string(),
diff --git a/packages/api/src/router/custom-widget/management-query-mappers.ts b/packages/api/src/router/custom-widget/management-query-mappers.ts
index 086cae565..7f1dba647 100644
--- a/packages/api/src/router/custom-widget/management-query-mappers.ts
+++ b/packages/api/src/router/custom-widget/management-query-mappers.ts
@@ -20,6 +20,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
+ event: "custom_widget_definition_invalid",
id: definition.id,
issueCount: result.issues.length,
});
@@ -88,7 +89,11 @@ export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
export function mapAvailableCustomWidget(definition: StoredDefinition) {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
- logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
+ logger.warn("Excluded invalid custom widget definition from board picker", {
+ event: "custom_widget_definition_invalid",
+ id: definition.id,
+ surface: "board-picker",
+ });
return [];
}
diff --git a/packages/api/src/router/custom-widget/metadata-procedures.ts b/packages/api/src/router/custom-widget/metadata-procedures.ts
index e7accd24a..3cfd4391b 100644
--- a/packages/api/src/router/custom-widget/metadata-procedures.ts
+++ b/packages/api/src/router/custom-widget/metadata-procedures.ts
@@ -4,15 +4,16 @@ import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/autho
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
-import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
+import { permissionRequiredProcedure } from "../../trpc";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const metadataProcedures = {
- schema: publicProcedure
+ schema: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
.query(() => getCustomWidgetJsonSchema()),
- getAuthoringPrompt: publicProcedure
+ getAuthoringPrompt: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
.query(() => ({
version: 2,
@@ -25,11 +26,13 @@ export const metadataProcedures = {
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
- getSkill: publicProcedure
+ getSkill: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.query(() => getCustomWidgetSkill()),
- validate: manageProcedure
+ validate: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
diff --git a/packages/api/src/router/custom-widget/preview-action-procedures.ts b/packages/api/src/router/custom-widget/preview-action-procedures.ts
index b3fe948a2..a4e50dd4a 100644
--- a/packages/api/src/router/custom-widget/preview-action-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-action-procedures.ts
@@ -12,10 +12,9 @@ import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValue
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession } from "./preview-sessions";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-
export const previewActionProcedures = {
- previewAction: manageProcedure
+ previewAction: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
})
diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts
index 7a0cc7b4a..3d34fad46 100644
--- a/packages/api/src/router/custom-widget/preview-base-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts
@@ -17,8 +17,6 @@ import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-
const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
@@ -26,13 +24,11 @@ const previewCreateInputSchema = z.object({
options: z.record(z.string(), z.unknown()).optional(),
});
-const previewCreateProcedure = manageProcedure
+const previewCreateProcedure = permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
.input(previewCreateInputSchema)
.mutation(async ({ ctx, input }) => {
- if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({ code: "FORBIDDEN", message: "Writing preview secrets requires dedicated permission" });
- }
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
if (optionIssues.length > 0) {
@@ -62,7 +58,7 @@ const previewCreateProcedure = manageProcedure
);
if (missingReplacement) {
throw new TRPCError({
- code: ctx.session.user.permissions.includes("custom-widget-secret-write") ? "BAD_REQUEST" : "FORBIDDEN",
+ code: "BAD_REQUEST",
message: "Source security settings changed; re-enter its credentials to preview this definition",
});
}
@@ -123,25 +119,28 @@ const previewCreateProcedure = manageProcedure
export const previewBaseProcedures = {
previewCreate: previewCreateProcedure,
- previewGet: manageProcedure.input(z.object({ sessionId: z.string() })).query(async ({ ctx, input }) => {
- const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
- return {
- id: session.id,
- name: session.name,
- expiresAt: session.expiresAt,
- template: session.template,
- optionDefinitions: session.optionDefinitions,
- options: session.options,
- requests: Object.entries(session.requests).map(([id, request]) => ({
- id,
- kind: request.kind,
- method: request.method,
- minimumBoardPermission: request.permission,
- trigger: request.trigger,
- confirmation: getCustomWidgetConfirmation(request),
- invalidates: request.invalidates,
- })),
- liveActions: session.liveActions,
- };
- }),
+ previewGet: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ sessionId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
+ return {
+ id: session.id,
+ name: session.name,
+ expiresAt: session.expiresAt,
+ template: session.template,
+ optionDefinitions: session.optionDefinitions,
+ options: session.options,
+ requests: Object.entries(session.requests).map(([id, request]) => ({
+ id,
+ kind: request.kind,
+ method: request.method,
+ minimumBoardPermission: request.permission,
+ trigger: request.trigger,
+ confirmation: getCustomWidgetConfirmation(request),
+ invalidates: request.invalidates,
+ })),
+ liveActions: session.liveActions,
+ };
+ }),
};
diff --git a/packages/api/src/router/custom-widget/preview-query-procedures.ts b/packages/api/src/router/custom-widget/preview-query-procedures.ts
index 5059aa767..18e527c72 100644
--- a/packages/api/src/router/custom-widget/preview-query-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-query-procedures.ts
@@ -13,10 +13,9 @@ import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./req
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-
export const previewQueryProcedures = {
- previewQuery: manageProcedure
+ previewQuery: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -76,13 +75,15 @@ export const previewQueryProcedures = {
}
}),
- setPreviewLiveActions: manageProcedure
+ setPreviewLiveActions: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) =>
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
),
- previewJournal: manageProcedure
+ previewJournal: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({
mcp: {
enabled: true,
diff --git a/packages/api/src/router/custom-widget/secret-persistence.ts b/packages/api/src/router/custom-widget/secret-persistence.ts
new file mode 100644
index 000000000..73f570510
--- /dev/null
+++ b/packages/api/src/router/custom-widget/secret-persistence.ts
@@ -0,0 +1,289 @@
+import { TRPCError } from "@trpc/server";
+
+import { encryptSecret } from "@homarr/common/server";
+import type { Database } from "@homarr/db";
+import { and, eq, handleTransactionsAsync } from "@homarr/db";
+import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
+import type { CustomWidgetCreateInput, CustomWidgetSecretKind, CustomWidgetSource } from "@homarr/custom-widgets/core";
+import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
+
+import { assertSecretSources, hasSameSecretBinding } from "./secret-policy";
+import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
+
+type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
+
+type ConfigureCustomWidgetSourceInput = {
+ definitionId: string;
+ sourceId: string;
+ baseUrl: string;
+ networkScope?: CustomWidgetSource["networkScope"];
+ secrets: CustomWidgetCreateInput["secrets"];
+ expectedSource?: CustomWidgetSource;
+};
+
+type SourceConfigurationIssue = "definition-not-found" | "source-not-found" | "binding-changed";
+
+class SourceConfigurationError extends Error {
+ constructor(readonly issue: SourceConfigurationIssue) {
+ super(`Custom widget source configuration failed: ${issue}`);
+ this.name = "SourceConfigurationError";
+ }
+}
+
+export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) {
+ const updatedAt = new Date();
+ const secretRow = {
+ definitionId,
+ sourceId: secret.sourceId,
+ kind: secret.kind,
+ encryptedValue: encryptSecret(secret.value),
+ updatedAt,
+ };
+ await handleTransactionsAsync(db, {
+ async handleAsync(database, schema) {
+ await database.transaction(async (transaction) => {
+ await transaction
+ .delete(schema.customWidgetSecrets)
+ .where(
+ and(
+ eq(schema.customWidgetSecrets.definitionId, definitionId),
+ eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
+ eq(schema.customWidgetSecrets.kind, secret.kind),
+ ),
+ );
+ await transaction.insert(schema.customWidgetSecrets).values(secretRow);
+ await transaction
+ .update(schema.customWidgetDefinitions)
+ .set({ updatedAt })
+ .where(eq(schema.customWidgetDefinitions.id, definitionId));
+ });
+ },
+ handleSync(database) {
+ database.transaction((transaction) => {
+ transaction
+ .delete(customWidgetSecrets)
+ .where(
+ and(
+ eq(customWidgetSecrets.definitionId, definitionId),
+ eq(customWidgetSecrets.sourceId, secret.sourceId),
+ eq(customWidgetSecrets.kind, secret.kind),
+ ),
+ )
+ .run();
+ transaction.insert(customWidgetSecrets).values(secretRow).run();
+ transaction
+ .update(customWidgetDefinitions)
+ .set({ updatedAt })
+ .where(eq(customWidgetDefinitions.id, definitionId))
+ .run();
+ });
+ },
+ });
+}
+
+export async function configureCustomWidgetSource(db: Database, input: ConfigureCustomWidgetSourceInput) {
+ try {
+ return await persistCustomWidgetSourceConfiguration(db, input);
+ } catch (error) {
+ if (!(error instanceof SourceConfigurationError)) throw error;
+ if (error.issue === "definition-not-found") throw new TRPCError({ code: "NOT_FOUND" });
+ if (error.issue === "source-not-found") {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
+ }
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Widget source authentication changed while it was being configured",
+ });
+ }
+}
+
+export async function configureCustomWidgetSourceFromRequest(
+ db: Database,
+ input: ConfigureCustomWidgetSourceInput & { expectedSource: CustomWidgetSource },
+) {
+ try {
+ return {
+ status: "configured" as const,
+ source: await persistCustomWidgetSourceConfiguration(db, input),
+ };
+ } catch (error) {
+ if (!(error instanceof SourceConfigurationError)) throw error;
+ return { status: error.issue } as const;
+ }
+}
+
+async function persistCustomWidgetSourceConfiguration(db: Database, input: ConfigureCustomWidgetSourceInput) {
+ let configuredSource: CustomWidgetSource | undefined;
+ const secretRows = input.secrets.map((secret) => ({
+ definitionId: input.definitionId,
+ sourceId: input.sourceId,
+ kind: secret.kind,
+ encryptedValue: encryptSecret(secret.value),
+ updatedAt: new Date(),
+ }));
+ await handleTransactionsAsync(db, {
+ async handleAsync(database, schema) {
+ await database.transaction(async (transaction) => {
+ const [stored] = await transaction
+ .select()
+ .from(schema.customWidgetDefinitions)
+ .where(eq(schema.customWidgetDefinitions.id, input.definitionId))
+ .limit(1)
+ .for("update");
+ const sourceUpdate = prepareSourceUpdate(stored, input);
+ configuredSource = sourceUpdate.source;
+ await transaction
+ .update(schema.customWidgetDefinitions)
+ .set(sourceUpdate.definitionChanges)
+ .where(eq(schema.customWidgetDefinitions.id, input.definitionId));
+ if (sourceUpdate.bindingChanged) {
+ await transaction
+ .delete(schema.customWidgetSecrets)
+ .where(
+ and(
+ eq(schema.customWidgetSecrets.definitionId, input.definitionId),
+ eq(schema.customWidgetSecrets.sourceId, input.sourceId),
+ ),
+ );
+ }
+ for (const secret of secretRows) {
+ await transaction
+ .delete(schema.customWidgetSecrets)
+ .where(
+ and(
+ eq(schema.customWidgetSecrets.definitionId, input.definitionId),
+ eq(schema.customWidgetSecrets.sourceId, input.sourceId),
+ eq(schema.customWidgetSecrets.kind, secret.kind),
+ ),
+ );
+ await transaction.insert(schema.customWidgetSecrets).values(secret);
+ }
+ });
+ },
+ handleSync(database) {
+ database.transaction(
+ (transaction) => {
+ const stored = transaction
+ .select()
+ .from(customWidgetDefinitions)
+ .where(eq(customWidgetDefinitions.id, input.definitionId))
+ .limit(1)
+ .get();
+ const sourceUpdate = prepareSourceUpdate(stored, input);
+ configuredSource = sourceUpdate.source;
+ transaction
+ .update(customWidgetDefinitions)
+ .set(sourceUpdate.definitionChanges)
+ .where(eq(customWidgetDefinitions.id, input.definitionId))
+ .run();
+ if (sourceUpdate.bindingChanged) {
+ transaction
+ .delete(customWidgetSecrets)
+ .where(
+ and(
+ eq(customWidgetSecrets.definitionId, input.definitionId),
+ eq(customWidgetSecrets.sourceId, input.sourceId),
+ ),
+ )
+ .run();
+ }
+ for (const secret of secretRows) {
+ transaction
+ .delete(customWidgetSecrets)
+ .where(
+ and(
+ eq(customWidgetSecrets.definitionId, input.definitionId),
+ eq(customWidgetSecrets.sourceId, input.sourceId),
+ eq(customWidgetSecrets.kind, secret.kind),
+ ),
+ )
+ .run();
+ transaction.insert(customWidgetSecrets).values(secret).run();
+ }
+ },
+ { behavior: "immediate" },
+ );
+ },
+ });
+ if (!configuredSource) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+ return configuredSource;
+}
+
+function prepareSourceUpdate(
+ stored: Parameters[0] | undefined,
+ input: ConfigureCustomWidgetSourceInput,
+) {
+ if (!stored) throw new SourceConfigurationError("definition-not-found");
+ const definition = parseStoredCustomWidgetDefinition(stored);
+ const previousSource = definition.sources[input.sourceId];
+ if (!previousSource) throw new SourceConfigurationError("source-not-found");
+ if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
+ throw new SourceConfigurationError("binding-changed");
+ }
+ const source = customWidgetSourceSchema.parse({
+ ...previousSource,
+ baseUrl: input.baseUrl,
+ networkScope: input.networkScope ?? previousSource.networkScope,
+ });
+ assertSecretSources({ [input.sourceId]: source }, input.secrets);
+ return {
+ source,
+ bindingChanged: !hasSameSecretBinding(previousSource, source),
+ definitionChanges: {
+ ...serializeCustomWidgetDefinition({
+ ...definition,
+ sources: { ...definition.sources, [input.sourceId]: source },
+ }),
+ updatedAt: new Date(),
+ },
+ };
+}
+
+export async function clearCustomWidgetSecret(
+ db: Database,
+ input: {
+ definitionId: string;
+ sourceId: string;
+ kind: CustomWidgetSecretKind;
+ },
+) {
+ const updatedAt = new Date();
+ await handleTransactionsAsync(db, {
+ async handleAsync(database, schema) {
+ await database.transaction(async (transaction) => {
+ await transaction
+ .delete(schema.customWidgetSecrets)
+ .where(
+ and(
+ eq(schema.customWidgetSecrets.definitionId, input.definitionId),
+ eq(schema.customWidgetSecrets.sourceId, input.sourceId),
+ eq(schema.customWidgetSecrets.kind, input.kind),
+ ),
+ );
+ await transaction
+ .update(schema.customWidgetDefinitions)
+ .set({ updatedAt })
+ .where(eq(schema.customWidgetDefinitions.id, input.definitionId));
+ });
+ },
+ handleSync(database) {
+ database.transaction((transaction) => {
+ transaction
+ .delete(customWidgetSecrets)
+ .where(
+ and(
+ eq(customWidgetSecrets.definitionId, input.definitionId),
+ eq(customWidgetSecrets.sourceId, input.sourceId),
+ eq(customWidgetSecrets.kind, input.kind),
+ ),
+ )
+ .run();
+ transaction
+ .update(customWidgetDefinitions)
+ .set({ updatedAt })
+ .where(eq(customWidgetDefinitions.id, input.definitionId))
+ .run();
+ });
+ },
+ });
+}
diff --git a/packages/api/src/router/custom-widget/secret-procedures.ts b/packages/api/src/router/custom-widget/secret-procedures.ts
index 6cb250682..8e0e6db58 100644
--- a/packages/api/src/router/custom-widget/secret-procedures.ts
+++ b/packages/api/src/router/custom-widget/secret-procedures.ts
@@ -1,9 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
-import { encryptSecret } from "@homarr/common/server";
-import { and, eq, handleTransactionsAsync } from "@homarr/db";
-import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
+import { eq } from "@homarr/db";
+import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetSecretInputSchema,
customWidgetSecretsInputSchema,
@@ -16,12 +15,10 @@ import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
-import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
+import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
+import { clearCustomWidgetSecret, configureCustomWidgetSource, setCustomWidgetSecret } from "./secret-persistence";
import { getPreviewSession } from "./preview-sessions";
-import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
-
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-const secretWriteProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-secret-write");
+import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const secretRequestInputSchema = z
.object({
@@ -44,7 +41,8 @@ const secretRequestInputSchema = z
});
export const secretProcedures = {
- secretSet: secretWriteProcedure
+ secretSet: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Set one encrypted secret for a custom widget source." } })
.input(z.object({ definitionId: z.string(), secret: customWidgetSecretInputSchema }))
.mutation(async ({ ctx, input }) => {
@@ -54,30 +52,12 @@ export const secretProcedures = {
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(existing);
assertSecretSources(definition.sources, [input.secret]);
- await ctx.db
- .delete(customWidgetSecrets)
- .where(
- and(
- eq(customWidgetSecrets.definitionId, input.definitionId),
- eq(customWidgetSecrets.sourceId, input.secret.sourceId),
- eq(customWidgetSecrets.kind, input.secret.kind),
- ),
- );
- await ctx.db.insert(customWidgetSecrets).values({
- definitionId: input.definitionId,
- sourceId: input.secret.sourceId,
- kind: input.secret.kind,
- encryptedValue: encryptSecret(input.secret.value),
- updatedAt: new Date(),
- });
- await ctx.db
- .update(customWidgetDefinitions)
- .set({ updatedAt: new Date() })
- .where(eq(customWidgetDefinitions.id, input.definitionId));
+ await setCustomWidgetSecret(ctx.db, input.definitionId, input.secret);
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
}),
- sourceConfigure: manageProcedure
+ sourceConfigure: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
@@ -89,9 +69,6 @@ export const secretProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
- if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({ code: "FORBIDDEN", message: "Writing source credentials requires dedicated permission" });
- }
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
@@ -105,91 +82,24 @@ export const secretProcedures = {
networkScope: input.networkScope ?? current.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
- const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
- const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() };
- const bindingChanged = !hasSameSecretBinding(current, source);
- const secretRows = input.secrets.map((secret) => ({
+ const configuredSource = await configureCustomWidgetSource(ctx.db, {
definitionId: input.definitionId,
sourceId: input.sourceId,
- kind: secret.kind,
- encryptedValue: encryptSecret(secret.value),
- updatedAt: new Date(),
- }));
- await handleTransactionsAsync(ctx.db, {
- async handleAsync(db, schema) {
- await db.transaction(async (transaction) => {
- await transaction
- .update(schema.customWidgetDefinitions)
- .set(definitionChanges)
- .where(eq(schema.customWidgetDefinitions.id, input.definitionId));
- if (bindingChanged) {
- await transaction
- .delete(schema.customWidgetSecrets)
- .where(
- and(
- eq(schema.customWidgetSecrets.definitionId, input.definitionId),
- eq(schema.customWidgetSecrets.sourceId, input.sourceId),
- ),
- );
- }
- for (const secret of secretRows) {
- await transaction
- .delete(schema.customWidgetSecrets)
- .where(
- and(
- eq(schema.customWidgetSecrets.definitionId, input.definitionId),
- eq(schema.customWidgetSecrets.sourceId, input.sourceId),
- eq(schema.customWidgetSecrets.kind, secret.kind),
- ),
- );
- await transaction.insert(schema.customWidgetSecrets).values(secret);
- }
- });
- },
- handleSync(db) {
- db.transaction((transaction) => {
- transaction
- .update(customWidgetDefinitions)
- .set(definitionChanges)
- .where(eq(customWidgetDefinitions.id, input.definitionId))
- .run();
- if (bindingChanged) {
- transaction
- .delete(customWidgetSecrets)
- .where(
- and(
- eq(customWidgetSecrets.definitionId, input.definitionId),
- eq(customWidgetSecrets.sourceId, input.sourceId),
- ),
- )
- .run();
- }
- for (const secret of secretRows) {
- transaction
- .delete(customWidgetSecrets)
- .where(
- and(
- eq(customWidgetSecrets.definitionId, input.definitionId),
- eq(customWidgetSecrets.sourceId, input.sourceId),
- eq(customWidgetSecrets.kind, secret.kind),
- ),
- )
- .run();
- transaction.insert(customWidgetSecrets).values(secret).run();
- }
- });
- },
+ baseUrl: source.baseUrl,
+ networkScope: input.networkScope,
+ secrets: input.secrets,
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
- baseUrl: source.baseUrl,
- networkScope: source.networkScope,
+ baseUrl: configuredSource.baseUrl,
+ networkScope: configuredSource.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
};
}),
- configurationRequestUser: manageProcedure
+ configurationRequestUser: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
.input(secretRequestInputSchema)
.mutation(async ({ ctx, input }) => {
@@ -203,12 +113,6 @@ export const secretProcedures = {
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
- if (!ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Creating a stored credential setup link requires dedicated permission",
- });
- }
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
@@ -258,7 +162,8 @@ export const secretProcedures = {
};
}),
- secretClear: secretWriteProcedure
+ secretClear: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
)
@@ -267,19 +172,7 @@ export const secretProcedures = {
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
- await ctx.db
- .delete(customWidgetSecrets)
- .where(
- and(
- eq(customWidgetSecrets.definitionId, input.definitionId),
- eq(customWidgetSecrets.sourceId, input.sourceId),
- eq(customWidgetSecrets.kind, input.kind),
- ),
- );
- await ctx.db
- .update(customWidgetDefinitions)
- .set({ updatedAt: new Date() })
- .where(eq(customWidgetDefinitions.id, input.definitionId));
+ await clearCustomWidgetSecret(ctx.db, input);
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
}),
};
diff --git a/packages/api/src/router/custom-widget/template-procedures.ts b/packages/api/src/router/custom-widget/template-procedures.ts
index 116340800..3893936ae 100644
--- a/packages/api/src/router/custom-widget/template-procedures.ts
+++ b/packages/api/src/router/custom-widget/template-procedures.ts
@@ -11,7 +11,6 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
@@ -33,22 +32,26 @@ const validateTemplate = (definition: ReturnType {
- const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
- where: eq(customWidgetDefinitions.id, input.id),
- });
- if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
- const template = parseStoredCustomWidgetDefinition(definition).template;
- return {
- id: definition.id,
- name: definition.name,
- template,
- templateLines: template.split("\n"),
- revision: getTemplateRevision(template),
- };
- }),
+ readTemplate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, input.id),
+ });
+ if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
+ const template = parseStoredCustomWidgetDefinition(definition).template;
+ return {
+ id: definition.id,
+ name: definition.name,
+ template,
+ templateLines: template.split("\n"),
+ revision: getTemplateRevision(template),
+ };
+ }),
- writeTemplate: manageProcedure
+ writeTemplate: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(
z
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
@@ -74,7 +77,8 @@ export const templateProcedures = {
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
}),
- templatePatch: manageProcedure
+ templatePatch: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
.input(
z.object({
diff --git a/packages/api/src/router/custom-widget/transfer-procedures.ts b/packages/api/src/router/custom-widget/transfer-procedures.ts
index cc6f25ed2..4be5619d9 100644
--- a/packages/api/src/router/custom-widget/transfer-procedures.ts
+++ b/packages/api/src/router/custom-widget/transfer-procedures.ts
@@ -7,39 +7,37 @@ import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
-import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
+import { permissionRequiredProcedure } from "../../trpc";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
- export: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
- const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
- where: eq(customWidgetDefinitions.id, input.id),
- });
- if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
- return parseStoredCustomWidgetDefinition(definition);
- }),
+ export: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, input.id),
+ });
+ if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
+ return parseStoredCustomWidgetDefinition(definition);
+ }),
- import: manageProcedure
+ import: permissionRequiredProcedure
+ .requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
- if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Writing imported widget secrets requires dedicated permission",
- });
- }
assertSecretSources(input.widget.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
- migrateLegacy: manageProcedure
+ migrateLegacy: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -55,12 +53,6 @@ export const transferProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
- if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Writing migrated widget secrets requires dedicated permission",
- });
- }
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
@@ -108,41 +100,21 @@ export const transferProcedures = {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
- await transaction
- .delete(schema.legacyCustomWidgetDefinitions)
- .where(eq(schema.legacyCustomWidgetDefinitions.id, legacy.id));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
- transaction
- .delete(legacyCustomWidgetDefinitions)
- .where(eq(legacyCustomWidgetDefinitions.id, legacy.id))
- .run();
});
},
});
- logger.info("Migrated legacy custom widget definition", {
+ logger.info("Created v2 replacement for preserved legacy custom widget definition", {
id: legacy.id,
preservedSecretCount: preservedSecrets.length,
});
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
}),
-
- 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/router/custom-widget/workshop-procedures.ts b/packages/api/src/router/custom-widget/workshop-procedures.ts
index c18b98f11..703b012f7 100644
--- a/packages/api/src/router/custom-widget/workshop-procedures.ts
+++ b/packages/api/src/router/custom-widget/workshop-procedures.ts
@@ -10,15 +10,35 @@ import {
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
} from "@homarr/custom-widgets/core";
+import { createLogger } from "@homarr/core/infrastructure/logs";
import { WorkshopBackend } from "@homarr/workshop/backend";
-import { validateWorkshopWidget, WORKSHOP_API_URL } from "@homarr/workshop/schema";
+import { resolveHomarrUrlConfig, validateWorkshopWidget } from "@homarr/workshop/schema";
+import { env } from "../../env";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
-const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-const workshop = new WorkshopBackend(process.env.WORKSHOP_API_URL ?? WORKSHOP_API_URL);
+const logger = createLogger({ module: "custom-widget:workshop" });
+const workshopUrls = resolveHomarrUrlConfig({
+ homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
+ workshopApiUrl: env.WORKSHOP_API_URL,
+});
+const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
+
+function throwWorkshopUnavailable(
+ message: string,
+ event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
+): never {
+ logger.error(message, {
+ event,
+ errorName: "WorkshopBackendError",
+ });
+ throw new TRPCError({
+ code: "BAD_GATEWAY",
+ message: "Workshop is unavailable",
+ });
+}
async function getWorkshopWidget(submissionId: string) {
try {
@@ -36,11 +56,7 @@ async function getWorkshopWidget(submissionId: string) {
return { submission, widget: validation.data };
} catch (error) {
if (error instanceof TRPCError) throw error;
- throw new TRPCError({
- code: "BAD_GATEWAY",
- message: error instanceof Error ? error.message : "Workshop is unavailable",
- cause: error,
- });
+ throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
}
}
@@ -59,7 +75,8 @@ const sourceOverridesSchema = z.record(
);
export const workshopProcedures = {
- workshopSearch: manageProcedure
+ workshopSearch: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
.input(workshopSearchInputSchema)
.query(async ({ input }) => {
@@ -86,16 +103,13 @@ export const workshopProcedures = {
reportCount: item.reportCount,
})),
};
- } catch (error) {
- throw new TRPCError({
- code: "BAD_GATEWAY",
- message: error instanceof Error ? error.message : "Workshop is unavailable",
- cause: error,
- });
+ } catch {
+ throwWorkshopUnavailable("Workshop widget search failed", "workshop_widget_search_failed");
}
}),
- workshopGet: manageProcedure
+ workshopGet: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
.input(z.object({ submissionId: z.string().min(1) }))
.query(async ({ input }) => {
@@ -119,7 +133,8 @@ export const workshopProcedures = {
};
}),
- workshopInstall: manageProcedure
+ workshopInstall: permissionRequiredProcedure
+ .requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
.input(
z.object({
@@ -130,12 +145,6 @@ export const workshopProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
- if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Writing Workshop widget credentials requires dedicated permission",
- });
- }
const { widget } = await getWorkshopWidget(input.submissionId);
const unknownSource = Object.keys(input.sources).find((sourceId) => !widget.sources[sourceId]);
if (unknownSource) {
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 44cc97adc..5eae4dbc1 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -1483,6 +1483,63 @@ describe("saveLayouts should save layout changes", () => {
});
});
+describe("Custom Widget placement permissions", () => {
+ test("rejects a direct addItem request from a non-admin board modifier", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId } = await createFullBoardAsync(db, "custom-widget-add");
+
+ await expect(
+ caller.addItem({
+ boardId,
+ kind: "customApi",
+ options: {
+ definitionId: "definition-1",
+ refreshInterval: 30,
+ configuration: {},
+ },
+ integrationIds: [],
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ test("rejects a direct saveBoard request that adds a Custom Widget for a non-admin", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "custom-widget-save");
+
+ await expect(
+ caller.saveBoard({
+ id: boardId,
+ sections: [{ id: sectionId, kind: "empty", yOffset: 0, xOffset: 0 }],
+ items: [
+ {
+ id: createId(),
+ kind: "customApi",
+ options: {
+ definitionId: "definition-1",
+ refreshInterval: 30,
+ configuration: {},
+ },
+ integrationIds: [],
+ advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
+ layouts: [
+ {
+ layoutId,
+ sectionId,
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ },
+ ],
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+});
+
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited>,
diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
index e634cca89..3913e8901 100644
--- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
+++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
@@ -9,24 +9,26 @@ import {
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
-import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
+import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
+import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
+import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
const userId = createId();
const session = {
user: {
id: userId,
- permissions: ["custom-widget-manage", "custom-widget-secret-write"],
+ permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
-const manageOnlySession = {
+const nonAdminSession = {
...session,
- user: { ...session.user, permissions: ["custom-widget-manage"] },
+ user: { ...session.user, permissions: ["board-modify-all"] },
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
@@ -38,11 +40,11 @@ function createCaller(db: ReturnType) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
-function createManageOnlyCaller(db: ReturnType) {
+function createNonAdminCaller(db: ReturnType) {
return customWidgetRouter.createCaller({
db,
deviceType: undefined,
- session: manageOnlySession,
+ session: nonAdminSession,
});
}
@@ -54,14 +56,24 @@ async function prepareDatabase() {
function rejectSecretInserts(db: ReturnType) {
db.run(
- sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
- BEFORE INSERT ON custom_widget_secret
+ sql.raw(`CREATE TRIGGER reject_custom_widget_v2_secret_insert
+ BEFORE INSERT ON custom_widget_v2_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
+function rejectDefinitionUpdates(db: ReturnType) {
+ db.run(
+ sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
+ BEFORE UPDATE ON custom_widget_v2_definition
+ BEGIN
+ SELECT RAISE(ABORT, 'forced definition update failure');
+ END`),
+ );
+}
+
async function insertLegacyJellyfin(db: ReturnType) {
const id = "legacy-jellyfin";
const encryptedValue = "preserved.secret" as `${string}.${string}`;
@@ -88,13 +100,21 @@ async function insertLegacyJellyfin(db: ReturnType) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
- test("atomically replaces a confirmed legacy widget while preserving its stable ID and credential", async () => {
+ test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
- expect(await db.query.legacyCustomWidgetDefinitions.findMany()).toHaveLength(0);
+ expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({
+ id: legacy.id,
+ name: "Legacy Jellyfin",
+ enabled: false,
+ });
+ expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
+ definitionId: legacy.id,
+ encryptedValue: legacy.encryptedValue,
+ });
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
enabled: false,
@@ -106,6 +126,20 @@ describe("custom widget definition persistence", () => {
kind: "apiKey",
encryptedValue: legacy.encryptedValue,
});
+
+ const migratedList = await createCaller(db).list();
+ expect(migratedList).toHaveLength(1);
+ expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
+
+ await createCaller(db).delete({ id: legacy.id });
+
+ const fallbackList = await createCaller(db).list();
+ expect(fallbackList).toHaveLength(1);
+ expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
+ expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
+ definitionId: legacy.id,
+ encryptedValue: legacy.encryptedValue,
+ });
});
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
@@ -149,6 +183,30 @@ describe("custom widget definition persistence", () => {
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
+ test("refuses to export a stored definition containing embedded credentials", async () => {
+ const db = await prepareDatabase();
+ const definition = customWidgetDefinitionSchema.parse(jellyfin);
+ const counts = definition.requests.counts;
+ if (!counts) throw new Error("Jellyfin counts request is missing");
+ const id = createId();
+ await db.insert(customWidgetDefinitions).values({
+ id,
+ ...serializeCustomWidgetDefinition({
+ ...definition,
+ requests: {
+ ...definition.requests,
+ counts: {
+ ...counts,
+ headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
+ },
+ },
+ }),
+ creatorId: userId,
+ });
+
+ await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
+ });
+
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -180,6 +238,69 @@ describe("custom widget definition persistence", () => {
expect(storedSecret).toEqual(originalSecret);
});
+ test("keeps the existing secret when setting its replacement fails", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const created = await caller.create({ ...jellyfin, secrets: [secret] });
+ const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, created.id),
+ });
+ const originalSecret = await db.query.customWidgetSecrets.findFirst({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ });
+ rejectSecretInserts(db);
+
+ await expect(
+ caller.secretSet({
+ definitionId: created.id,
+ secret: { ...secret, value: "replacement-secret" },
+ }),
+ ).rejects.toThrow("forced secret insert failure");
+
+ expect(
+ await db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, created.id),
+ }),
+ ).toEqual(originalDefinition);
+ expect(
+ await db.query.customWidgetSecrets.findFirst({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ }),
+ ).toEqual(originalSecret);
+ });
+
+ test("keeps the existing secret when clearing it cannot update the definition revision", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const created = await caller.create({ ...jellyfin, secrets: [secret] });
+ const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, created.id),
+ });
+ const originalSecret = await db.query.customWidgetSecrets.findFirst({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ });
+ rejectDefinitionUpdates(db);
+
+ await expect(
+ caller.secretClear({
+ definitionId: created.id,
+ sourceId: secret.sourceId,
+ kind: secret.kind,
+ }),
+ ).rejects.toThrow("forced definition update failure");
+
+ expect(
+ await db.query.customWidgetDefinitions.findFirst({
+ where: eq(customWidgetDefinitions.id, created.id),
+ }),
+ ).toEqual(originalDefinition);
+ expect(
+ await db.query.customWidgetSecrets.findFirst({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ }),
+ ).toEqual(originalSecret);
+ });
+
test("configures a source URL and credential together", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -249,7 +370,7 @@ describe("custom widget definition persistence", () => {
};
await expect(
- createManageOnlyCaller(db).previewCreate({
+ createNonAdminCaller(db).previewCreate({
definitionId: created.id,
definition: changedDefinition,
secrets: [],
@@ -260,11 +381,11 @@ describe("custom widget definition persistence", () => {
).resolves.toMatchObject({ success: true });
});
- test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
+ test("clears persisted credentials when an admin source edit changes its binding", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
- await createManageOnlyCaller(db).sourceConfigure({
+ await createCaller(db).sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "https://other.example/api",
@@ -279,12 +400,109 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
+ test("uses the persisted source binding from inside the configuration transaction", async () => {
+ const db = await prepareDatabase();
+ const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
+ const definition = customWidgetDefinitionSchema.parse(jellyfin);
+ const defaultSource = definition.sources.default;
+ if (!defaultSource) throw new Error("Jellyfin default source is missing");
+ await db
+ .update(customWidgetDefinitions)
+ .set(
+ serializeCustomWidgetDefinition({
+ ...definition,
+ name: "Concurrent definition edit",
+ sources: {
+ ...definition.sources,
+ default: { ...defaultSource, baseUrl: "https://changed.example/api" },
+ },
+ }),
+ )
+ .where(eq(customWidgetDefinitions.id, created.id));
+
+ await expect(
+ configureCustomWidgetSourceFromRequest(db, {
+ definitionId: created.id,
+ sourceId: "default",
+ baseUrl: defaultSource.baseUrl,
+ networkScope: defaultSource.networkScope,
+ secrets: [],
+ expectedSource: defaultSource,
+ }),
+ ).resolves.toMatchObject({
+ status: "configured",
+ source: { baseUrl: defaultSource.baseUrl },
+ });
+
+ expect(await createCaller(db).get({ id: created.id })).toMatchObject({
+ name: "Concurrent definition edit",
+ sources: { default: { baseUrl: defaultSource.baseUrl } },
+ });
+ expect(
+ await db.query.customWidgetSecrets.findMany({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ }),
+ ).toHaveLength(0);
+ });
+
+ test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
+ const db = await prepareDatabase();
+ const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
+ const definition = customWidgetDefinitionSchema.parse(jellyfin);
+ const expectedSource = definition.sources.default;
+ if (!expectedSource || typeof expectedSource.auth === "string") {
+ throw new Error("Jellyfin default source must use header authentication");
+ }
+ await db
+ .update(customWidgetDefinitions)
+ .set(
+ serializeCustomWidgetDefinition({
+ ...definition,
+ name: "Concurrent definition edit",
+ sources: {
+ ...definition.sources,
+ default: {
+ ...expectedSource,
+ auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
+ },
+ },
+ }),
+ )
+ .where(eq(customWidgetDefinitions.id, created.id));
+
+ await expect(
+ configureCustomWidgetSourceFromRequest(db, {
+ definitionId: created.id,
+ sourceId: "default",
+ baseUrl: "https://configured.example/api",
+ networkScope: "public",
+ secrets: [secret],
+ expectedSource,
+ }),
+ ).resolves.toEqual({ status: "binding-changed" });
+
+ expect(await createCaller(db).get({ id: created.id })).toMatchObject({
+ name: "Concurrent definition edit",
+ sources: {
+ default: {
+ baseUrl: expectedSource.baseUrl,
+ auth: { name: "X-Replaced-Credential" },
+ },
+ },
+ });
+ expect(
+ await db.query.customWidgetSecrets.findMany({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ }),
+ ).toHaveLength(0);
+ });
+
test("clears persisted credentials when a general definition update changes its binding", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
- await createManageOnlyCaller(db).update({
+ await createCaller(db).update({
id: created.id,
sources: {
...jellyfin.sources,
@@ -299,12 +517,12 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
- test("requires secret-write permission to create a stored-definition setup link", async () => {
+ test("requires an administrator to create a stored-definition setup link", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
await expect(
- createManageOnlyCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
+ createNonAdminCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
diff --git a/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts b/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts
index f2e7f3c4d..27b3fc4da 100644
--- a/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts
+++ b/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
+import { stringify as stringifySuperJson } from "superjson";
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
@@ -9,14 +10,14 @@ describe("legacy custom widget migration prompt", () => {
id: "legacy",
name: "Private service",
description: "Status",
- iconUrl: null,
+ iconUrl: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
authType: "bearer",
headerName: null,
method: "POST",
requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
displayType: "singleValue",
- displayConfig: JSON.stringify({ json: { type: "singleValue", jsonPath: "$.status" } }),
+ displayConfig: "corrupted-config-with-display-secret",
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
@@ -32,5 +33,40 @@ describe("legacy custom widget migration prompt", () => {
expect(prompt).not.toContain("body-secret");
expect(prompt).not.toContain("admin:password");
expect(prompt).not.toContain("path-token");
+ expect(prompt).not.toContain("icon-secret");
+ expect(prompt).not.toContain("icons:password");
+ expect(prompt).not.toContain("private/icon.svg");
+ expect(prompt).not.toContain("display-secret");
+ });
+
+ test("omits valid free-text display configuration from the migration prompt", () => {
+ const prompt = buildLegacyCustomWidgetMigrationPrompt(
+ {
+ id: "legacy-custom-jsx",
+ name: "Private service",
+ description: "Status",
+ iconUrl: null,
+ url: "https://example.test/api/status",
+ authType: "bearer",
+ headerName: null,
+ method: "GET",
+ requestBody: null,
+ displayType: "customJsx",
+ displayConfig: stringifySuperJson({
+ type: "customJsx",
+ template: "LEGACY_PRIVATE_MARKER Bearer sk-secret-123456 ",
+ }),
+ enabled: true,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ creatorId: null,
+ },
+ ["apiKey"],
+ );
+
+ expect(prompt).toContain("display configuration was intentionally omitted");
+ expect(prompt).not.toContain("LEGACY_PRIVATE_MARKER");
+ expect(prompt).not.toContain("Bearer sk-secret-123456");
+ expect(prompt).not.toContain("sk-secret-123456");
});
});
diff --git a/packages/api/src/router/test/custom-widget/workshop-error-privacy.spec.ts b/packages/api/src/router/test/custom-widget/workshop-error-privacy.spec.ts
new file mode 100644
index 000000000..1611e6288
--- /dev/null
+++ b/packages/api/src/router/test/custom-widget/workshop-error-privacy.spec.ts
@@ -0,0 +1,113 @@
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { createDb } from "@homarr/db/test";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ get: vi.fn(async (_id: string): Promise => undefined),
+ list: vi.fn(async (_options: unknown): Promise => undefined),
+ logger: {
+ debug: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ },
+}));
+
+vi.mock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => mocks.logger,
+}));
+
+vi.mock("@homarr/workshop/backend", () => ({
+ WorkshopBackend: class {
+ public get(id: string) {
+ return mocks.get(id);
+ }
+
+ public list(options: unknown) {
+ return mocks.list(options);
+ }
+ },
+}));
+
+import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
+
+const session = {
+ user: {
+ id: createId(),
+ permissions: ["admin"],
+ colorScheme: "light",
+ },
+ expires: new Date(Date.now() + 60_000).toISOString(),
+} satisfies Session;
+
+const createCaller = () =>
+ customWidgetRouter.createCaller({
+ db: createDb(),
+ deviceType: undefined,
+ session,
+ });
+
+const createTokenizedFailure = (apiKey: string) => {
+ const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
+ const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
+ error.name = `WorkshopTransportError:${tokenizedUrl}`;
+ return error;
+};
+
+const expectSafeFailure = (
+ error: unknown,
+ apiKey: string,
+ logMessage: string,
+ event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
+) => {
+ expect(error).toMatchObject({
+ code: "BAD_GATEWAY",
+ message: "Workshop is unavailable",
+ });
+ expect((error as Error).cause).toBeUndefined();
+ expect(String(error)).not.toContain(apiKey);
+ expect(String(error)).not.toContain("access_token");
+ expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
+ event,
+ errorName: "WorkshopBackendError",
+ });
+ expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
+ expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
+};
+
+describe("Workshop backend error privacy", () => {
+ beforeEach(() => {
+ mocks.get.mockReset();
+ mocks.list.mockReset();
+ mocks.logger.error.mockClear();
+ });
+
+ test("redacts backend lookup failures from TRPC output and logs", async () => {
+ const apiKey = "workshop-lookup-secret";
+ mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
+
+ const error = await createCaller()
+ .workshopGet({ submissionId: "submission-id" })
+ .then(
+ () => undefined,
+ (cause: unknown) => cause,
+ );
+
+ expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
+ });
+
+ test("redacts backend search failures from TRPC output and logs", async () => {
+ const apiKey = "workshop-search-secret";
+ mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
+
+ const error = await createCaller()
+ .workshopSearch({ query: "weather" })
+ .then(
+ () => undefined,
+ (cause: unknown) => cause,
+ );
+
+ expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
+ });
+});
diff --git a/packages/api/src/router/test/widgets/options.spec.ts b/packages/api/src/router/test/widgets/options.spec.ts
new file mode 100644
index 000000000..6253da22f
--- /dev/null
+++ b/packages/api/src/router/test/widgets/options.spec.ts
@@ -0,0 +1,107 @@
+import SuperJSON from "superjson";
+import { describe, expect, test } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import { boards, items, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { optionsRouter } from "../../widgets/options";
+
+const createSession = (userId: string, isAdmin = false): Session => ({
+ user: {
+ id: userId,
+ permissions: isAdmin ? ["admin"] : [],
+ colorScheme: "light",
+ },
+ expires: new Date(Date.now() + 60_000).toISOString(),
+});
+
+async function setup(kind: "customApi" | "weather") {
+ const db = createDb();
+ const userId = createId();
+ const boardId = createId();
+ const itemId = createId();
+ await db.insert(users).values({ id: userId });
+ await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
+ await db.insert(items).values({
+ id: itemId,
+ boardId,
+ kind,
+ options: SuperJSON.stringify(
+ kind === "customApi"
+ ? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
+ : { location: "Paris" },
+ ),
+ });
+ return { db, userId, boardId, itemId };
+}
+
+describe("saveItemOptions Custom Widget access", () => {
+ test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
+ const { db, userId, boardId, itemId } = await setup("customApi");
+ const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
+
+ await expect(
+ caller.saveItemOptions({
+ boardId,
+ itemId,
+ newOptions: {
+ definitionId: "definition-2",
+ refreshInterval: 1,
+ configuration: { server: "attacker" },
+ },
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+
+ const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
+ expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
+ definitionId: "definition-1",
+ refreshInterval: 30,
+ configuration: { server: "primary" },
+ });
+ });
+
+ test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
+ const { db, userId, boardId, itemId } = await setup("customApi");
+ const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
+
+ await expect(
+ caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
+ ).resolves.toBeUndefined();
+
+ const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
+ expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
+ });
+
+ test("allows normalized missing defaults without treating them as reconfiguration", async () => {
+ const { db, userId, boardId, itemId } = await setup("customApi");
+ const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
+ const options = SuperJSON.parse>(stored?.options ?? "");
+ delete options.configuration;
+ delete options.configurationVersion;
+ await db
+ .update(items)
+ .set({ options: SuperJSON.stringify(options) })
+ .where(eq(items.id, itemId));
+ const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
+
+ await expect(
+ caller.saveItemOptions({
+ boardId,
+ itemId,
+ newOptions: { configuration: {}, configurationVersion: 1 },
+ }),
+ ).resolves.toBeUndefined();
+ });
+
+ test("preserves ordinary board-modifier option updates for other widgets", async () => {
+ const { db, userId, boardId, itemId } = await setup("weather");
+ const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
+
+ await expect(
+ caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts
index e94ee31a4..873f1ab47 100644
--- a/packages/api/src/router/widgets/options.ts
+++ b/packages/api/src/router/widgets/options.ts
@@ -9,6 +9,7 @@ import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
+import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise => {
@@ -46,10 +47,15 @@ export const optionsRouter = createTRPCRouter({
}
const options = SuperJSON.parse>(item.options);
- Object.assign(options, input.newOptions);
+ const updatedOptions = { ...options, ...input.newOptions };
+ throwIfCustomWidgetPlacementChangeForbidden({
+ isAdmin: ctx.session.user.permissions.includes("admin"),
+ submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
+ storedItems: [{ id: item.id, kind: item.kind, options }],
+ });
await ctx.db
.update(items)
- .set({ options: SuperJSON.stringify(options) })
+ .set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
}),
});
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
index cf2f704fc..21165dd13 100644
--- a/packages/api/src/test/mcp.spec.ts
+++ b/packages/api/src/test/mcp.spec.ts
@@ -70,7 +70,6 @@ const MCP_TOOL_ALLOWLIST = {
"board_setHomeBoard",
"board_setMobileHomeBoard",
"customWidget_delete",
- "customWidget_deleteLegacy",
"customWidget_previewAction",
"customWidget_templatePatch",
"dnsHole_disable",
@@ -171,19 +170,11 @@ describe("custom widget authoring procedure access", () => {
session: null,
});
- test("executes promised public procedures without a session", async () => {
- await expect(unauthenticatedCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
- await expect(unauthenticatedCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
- version: 2,
- prompt: expect.any(String),
- });
- await expect(unauthenticatedCaller.customWidget.getSkill()).resolves.toMatchObject({
- name: expect.any(String),
- });
- });
-
- test("rejects protected and permission-gated authoring procedures without a session", async () => {
+ test("rejects every authoring procedure without a session", async () => {
const calls = [
+ unauthenticatedCaller.customWidget.schema(),
+ unauthenticatedCaller.customWidget.getAuthoringPrompt(),
+ unauthenticatedCaller.customWidget.getSkill(),
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
unauthenticatedCaller.customWidget.secretSet({
@@ -196,17 +187,39 @@ describe("custom widget authoring procedure access", () => {
}
});
- test("enforces the dedicated permission on secret tools", async () => {
- const callerWithoutSecretPermission = mcpRouter.createCaller({
+ test("rejects authoring and secret tools for authenticated non-admins", async () => {
+ const nonAdminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
- session: { user: { id: "user-1", permissions: ["custom-widget-manage"] } } as never,
+ session: { user: { id: "user-1", permissions: ["board-modify-all"] } } as never,
});
- await expect(
- callerWithoutSecretPermission.customWidget.secretSet({
+ const calls = [
+ nonAdminCaller.customWidget.schema(),
+ nonAdminCaller.customWidget.getAuthoringPrompt(),
+ nonAdminCaller.customWidget.getSkill(),
+ nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
}),
- ).rejects.toEqual(expect.objectContaining>({ code: "FORBIDDEN" }));
+ ];
+ for (const call of calls) {
+ await expect(call).rejects.toEqual(expect.objectContaining>({ code: "FORBIDDEN" }));
+ }
+ });
+
+ test("allows admins to read authoring resources", async () => {
+ const adminCaller = mcpRouter.createCaller({
+ db: null as never,
+ deviceType: undefined,
+ session: { user: { id: "admin-1", permissions: ["admin"] } } as never,
+ });
+ await expect(adminCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
+ await expect(adminCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
+ version: 2,
+ prompt: expect.any(String),
+ });
+ await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
+ name: expect.any(String),
+ });
});
});
diff --git a/packages/custom-widgets/scripts/ai-evaluation.ts b/packages/custom-widgets/scripts/ai-evaluation.ts
index 4c7c3d316..43e8ba56e 100644
--- a/packages/custom-widgets/scripts/ai-evaluation.ts
+++ b/packages/custom-widgets/scripts/ai-evaluation.ts
@@ -267,15 +267,7 @@ export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
}
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
- const { template, ...manifest } = widget;
- await Promise.all([
- writeFile(
- path.join(directory, `${basename}.widget.json`),
- JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2),
- "utf8",
- ),
- writeFile(path.join(directory, `${basename}.widget.jsx`), template, "utf8"),
- ]);
+ await writeFile(path.join(directory, `${basename}.widget.json`), JSON.stringify(widget, null, 2), "utf8");
}
async function callOpenRouter(args: {
diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts
index 575f19ae7..0a31a7f1c 100644
--- a/packages/custom-widgets/src/core/ai-prompt.ts
+++ b/packages/custom-widgets/src/core/ai-prompt.ts
@@ -1,12 +1,17 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
+import {
+ getCustomWidgetCredentialKeyRisk,
+ isHarmlessCustomWidgetCredentialSetting,
+ redactCustomWidgetCredentialLiterals,
+} from "./definition-security";
import { customJsxExamples } from "./examples";
export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVersion;
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
- "Return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Do not include prose outside those blocks.";
+ "Return exactly one complete `json` fenced block containing the entire widget. Put the complete JSX source directly in the `template` string and JSON-escape it correctly. Do not include prose or additional code blocks.";
const RECOMMENDED_COMPONENTS = [
"Stack",
@@ -76,14 +81,13 @@ const leanShape = `{
"category": { "label": "Category", "control": "text", "default": "all" },
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
},
- "template": "__HOMARR_TEMPLATE__"
+ "template": "{data.items?.name} "
}`;
function compactExample(index: number) {
const example = customJsxExamples[index];
if (!example) return "";
- const { template, ...manifest } = example.widget;
- return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``;
+ return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify(example.widget, null, 2)}\n\`\`\``;
}
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
@@ -93,7 +97,7 @@ ${leanShape}
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest.
-Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE requires full permission and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
+Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE is valid only for actions, requires full permission, and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option.
@@ -117,7 +121,7 @@ ${compactExample(0)}
${compactExample(1)}
-Output the manifest with template set exactly to "__HOMARR_TEMPLATE__", then put the complete template in the JSX block.`;
+Output one complete JSON manifest. Put the complete JSX directly in its template string so the user can copy one code block and paste it into Homarr once.`;
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
@@ -165,25 +169,42 @@ export function buildCustomWidgetAiPrompt(
return `${truncatePromptText(sections.join("\n\n"), CUSTOM_WIDGET_AI_PROMPT_LIMIT - footer.length)}${footer}`;
}
-const sensitiveKey =
- /(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
-
-function redactValue(value: unknown, key = ""): unknown {
- if (sensitiveKey.test(key)) return "[REDACTED]";
- if (Array.isArray(value)) return value.map((entry) => redactValue(entry));
+function redactValue(value: unknown, path: Array = []): unknown {
+ if (Array.isArray(value)) return value.map((entry, index) => redactValue(entry, [...path, index]));
if (value !== null && typeof value === "object")
return Object.fromEntries(
- Object.entries(value).map(([entryKey, entry]) => [entryKey, redactValue(entry, entryKey)]),
+ Object.entries(value).map(([entryKey, entry]) => {
+ const entryPath = [...path, entryKey];
+ const risk = getCustomWidgetCredentialKeyRisk(entryKey);
+ if (
+ risk === "strong" &&
+ !isSchemaAuthenticationControl(entryPath) &&
+ !isHarmlessCustomWidgetCredentialSetting(entry)
+ ) {
+ return [entryKey, "[REDACTED]"];
+ }
+ if (
+ risk === "ambiguous" &&
+ typeof entry === "string" &&
+ redactCustomWidgetCredentialLiterals(entry) !== entry
+ ) {
+ return [entryKey, "[REDACTED]"];
+ }
+ return [entryKey, redactValue(entry, entryPath)];
+ }),
);
if (typeof value === "string") {
+ const key = typeof path.at(-1) === "string" ? (path.at(-1) as string) : "";
if (["sources", "requests", "options"].includes(key)) {
try {
- return redactValue(JSON.parse(value) as unknown);
+ return redactValue(JSON.parse(value) as unknown, path);
} catch {
/* Keep invalid editor JSON useful. */
}
}
- return key === "baseUrl" || key === "iconUrl" ? redactUrl(value) : redactText(value);
+ return key === "baseUrl" || key === "iconUrl"
+ ? redactCustomWidgetCredentialLiterals(redactUrl(value))
+ : redactText(value);
}
return value;
}
@@ -210,13 +231,18 @@ function redactUrl(value: string): string {
}
function redactText(value: string): string {
- return value
- .replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactUrl(candidate))
- .replace(/\b(authorization\s*:\s*bearer)\s+[^\s,;]+/giu, "$1 [REDACTED]")
- .replace(
- /\b(api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)["']?\s*[:=]\s*["']?[^\s,;"'}]+/giu,
- "$1: [REDACTED]",
- );
+ return redactCustomWidgetCredentialLiterals(
+ value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactUrl(candidate)),
+ );
+}
+
+function isSchemaAuthenticationControl(path: Array): boolean {
+ return (
+ path.length === 3 &&
+ path[2] === "auth" &&
+ (path[0] === "sources" || path[0] === "requests") &&
+ typeof path[1] === "string"
+ );
}
function formatBudgetedCodeSection(label: string, content: string, budget: number): string | null {
diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts
index b5529a298..cf6f833e8 100644
--- a/packages/custom-widgets/src/core/custom-jsx-schema.ts
+++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts
@@ -12,6 +12,7 @@ import {
} from "./request-schema";
import type { CustomJsxRequest } from "./request-schema";
import { customWidgetSecretKinds } from "./schema-types";
+import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export * from "./options-schema";
export * from "./request-schema";
@@ -67,12 +68,12 @@ export const customWidgetDefinitionSchema = z
description: z.string().max(512).optional(),
iconUrl: z
.string()
+ .refine(
+ (value) => getCustomWidgetHttpUrlIssue(value) === null,
+ "Widget icons must use an unambiguous HTTP or HTTPS URL without embedded credentials",
+ )
.url()
- .refine((value) => {
- if (!URL.canParse(value)) return false;
- const url = new URL(value);
- return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
- }, "Widget icons must use HTTP or HTTPS without embedded credentials")
+ .max(2048)
.optional(),
sources: customWidgetSourcesSchema,
requests: customWidgetRequestsSchema,
diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts
index f2689dfe7..c5c215fcd 100644
--- a/packages/custom-widgets/src/core/definition-security.ts
+++ b/packages/custom-widgets/src/core/definition-security.ts
@@ -1,38 +1,210 @@
import type { z } from "zod/v4";
-const credentialKey =
- /(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
+type DefinitionPath = Array;
+
+const harmlessCredentialSettings = new Set([
+ "anonymous",
+ "authentication",
+ "basic",
+ "bearer",
+ "configured",
+ "default",
+ "disabled",
+ "enabled",
+ "example",
+ "false",
+ "inherit",
+ "missing",
+ "none",
+ "optional",
+ "placeholder",
+ "public",
+ "redacted",
+ "required",
+ "separate",
+ "separately",
+ "source",
+ "true",
+ "unset",
+]);
+
+const commonCredentialLiteralPattern =
+ /\b(?:sk|pk|rk)-(?:[A-Za-z0-9][A-Za-z0-9._-]{7,})\b|\b(?:sk_(?:live|test)|github_pat|glpat|gh[pousr]|hf_|xox[baprs])-?[A-Za-z0-9._-]{8,}\b|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\bAIza[0-9A-Za-z_-]{20,}\b|\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/giu;
+const authorizationSchemePattern = /\b(bearer|basic)([\s:_-]+)(["']?)([A-Za-z0-9._~+/%=-]{8,})/giu;
+const credentialAssignmentPattern =
+ /\b(authorization|auth(?:entication)?(?:[ _-]?(?:tokens?|keys?|credentials?))?|credentials?|api[ _-]?keys?|passwords?|passwds?|secrets?|tokens?|access[ _-]?(?:tokens?|keys?)|refresh[ _-]?tokens?|client[ _-]?secrets?|private[ _-]?keys?|signing[ _-]?keys?)((?:["']?\s*[:=]\s*["']?))([^\s,;"'}<>]+)/giu;
export function validateCredentialFreeExport(definition: Record, ctx: z.RefinementCtx) {
- inspect(definition.options, ["options"], ctx);
- inspect(definition.requests, ["requests"], ctx);
- if (typeof definition.iconUrl === "string" && URL.canParse(definition.iconUrl)) {
- const url = new URL(definition.iconUrl);
- if ([...url.searchParams.keys()].some((key) => credentialKey.test(key))) {
- ctx.addIssue({ code: "custom", path: ["iconUrl"], message: "Credentials must not be embedded in the icon URL" });
+ const { secrets: _secrets, ...exportedDefinition } = definition;
+ inspect(exportedDefinition, [], ctx);
+ inspectRequestPathQueries(definition.requests, ctx);
+ inspectUrlQuery(definition.iconUrl, ["iconUrl"], "Widget icon URLs", ctx);
+}
+
+function inspect(value: unknown, path: DefinitionPath, ctx: z.RefinementCtx): void {
+ if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
+ if (typeof value === "string") {
+ if (containsCustomWidgetCredentialLiteral(value)) {
+ ctx.addIssue({ code: "custom", path, message: "Credentials must use source authentication" });
}
+ return;
}
- if (
- typeof definition.template === "string" &&
- /(?:authorization\s*:\s*bearer|api[-_ ]?key\s*[:=]|password\s*[:=]|secret\s*[:=]|token\s*[:=])\s*["']?[A-Za-z0-9._~+/%=-]{8,}/iu.test(
- definition.template,
- )
- ) {
- ctx.addIssue({ code: "custom", path: ["template"], message: "Credentials must not be embedded in JSX" });
+ if (!value || typeof value !== "object") return;
+ for (const [key, child] of Object.entries(value)) {
+ const childPath = [...path, key];
+ const keyRisk = getCustomWidgetCredentialKeyRisk(key);
+ if (
+ keyRisk === "strong" &&
+ !isSchemaAuthenticationControl(childPath) &&
+ hasCredentialFieldValue(child) &&
+ !isHarmlessCustomWidgetCredentialSetting(child)
+ ) {
+ ctx.addIssue({ code: "custom", path: childPath, message: "Credentials must use source authentication" });
+ continue;
+ }
+ if (keyRisk === "ambiguous" && typeof child === "string" && containsCustomWidgetCredentialLiteral(child)) {
+ ctx.addIssue({ code: "custom", path: childPath, message: "Credentials must use source authentication" });
+ continue;
+ }
+ inspect(child, childPath, ctx);
}
}
-function inspect(value: unknown, path: Array, ctx: z.RefinementCtx): void {
- if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
- if (!value || typeof value !== "object") return;
- for (const [key, child] of Object.entries(value)) {
- if (credentialKey.test(key) && typeof child === "string" && child.trim()) {
- ctx.addIssue({ code: "custom", path: [...path, key], message: "Credentials must use source authentication" });
+function inspectRequestPathQueries(value: unknown, ctx: z.RefinementCtx): void {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
+ for (const [requestId, candidate] of Object.entries(value)) {
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
+ const path = (candidate as Record).path;
+ if (typeof path !== "string") continue;
+ const queryStart = path.indexOf("?");
+ if (queryStart < 0) continue;
+ const query = path.slice(queryStart + 1);
+ for (const [key, queryValue] of parseQueryEntries(query)) {
+ const keyRisk = getCustomWidgetCredentialKeyRisk(key);
+ if (
+ (keyRisk === "strong" && queryValue.length > 0 && !isHarmlessCustomWidgetCredentialSetting(queryValue)) ||
+ containsCustomWidgetCredentialLiteral(queryValue)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["requests", requestId, "path"],
+ message: "Request query credentials must use source authentication",
+ });
+ break;
+ }
}
- inspect(child, [...path, key], ctx);
}
}
+function inspectUrlQuery(value: unknown, path: DefinitionPath, label: string, ctx: z.RefinementCtx): void {
+ if (typeof value !== "string" || !URL.canParse(value)) return;
+ const url = new URL(value);
+ if (
+ parseQueryEntries(url.search).some(
+ ([key, queryValue]) =>
+ (getCustomWidgetCredentialKeyRisk(key) === "strong" && queryValue.length > 0) ||
+ containsCustomWidgetCredentialLiteral(queryValue),
+ )
+ ) {
+ ctx.addIssue({ code: "custom", path, message: `${label} cannot contain credentials` });
+ }
+}
+
+function parseQueryEntries(search: string): Array<[string, string]> {
+ const query = search.startsWith("?") ? search.slice(1) : search;
+ if (!query) return [];
+ return query.split("&").map((entry) => {
+ const separatorIndex = entry.indexOf("=");
+ const key = separatorIndex < 0 ? entry : entry.slice(0, separatorIndex);
+ const value = separatorIndex < 0 ? "" : entry.slice(separatorIndex + 1);
+ return [decodeQueryPart(key), decodeQueryPart(value)];
+ });
+}
+
+function decodeQueryPart(value: string): string {
+ try {
+ return decodeURIComponent(value.replaceAll("+", " "));
+ } catch {
+ return value;
+ }
+}
+
+export function getCustomWidgetCredentialKeyRisk(key: string): "strong" | "ambiguous" | null {
+ const normalized = key
+ .replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
+ .replace(/[^A-Za-z0-9]+/gu, " ")
+ .trim()
+ .toLowerCase();
+ if (!normalized) return null;
+ const phrases = [
+ "authorization",
+ "authentication",
+ "auth",
+ "credential",
+ "credentials",
+ "api key",
+ "api keys",
+ "password",
+ "passwords",
+ "passwd",
+ "passwds",
+ "secret",
+ "secrets",
+ "token",
+ "tokens",
+ "access token",
+ "access tokens",
+ "access key",
+ "access keys",
+ "refresh token",
+ "refresh tokens",
+ "client secret",
+ "client secrets",
+ "private key",
+ "private keys",
+ "signing key",
+ "signing keys",
+ ];
+ if (phrases.some((phrase) => normalized === phrase || normalized.endsWith(` ${phrase}`))) return "strong";
+ return normalized === "key" || normalized.endsWith(" key") || normalized === "keys" || normalized.endsWith(" keys")
+ ? "ambiguous"
+ : null;
+}
+
+export function isHarmlessCustomWidgetCredentialSetting(value: unknown): boolean {
+ if (value === null || value === undefined || value === "" || typeof value === "boolean") return true;
+ return typeof value === "string" && harmlessCredentialSettings.has(value.trim().toLowerCase());
+}
+
+export function containsCustomWidgetCredentialLiteral(value: string): boolean {
+ return redactCustomWidgetCredentialLiterals(value) !== value;
+}
+
+export function redactCustomWidgetCredentialLiterals(value: string): string {
+ return value
+ .replace(commonCredentialLiteralPattern, "[REDACTED]")
+ .replace(authorizationSchemePattern, (match, scheme: string, separator: string, quote: string, candidate: string) =>
+ isHarmlessCustomWidgetCredentialSetting(candidate) ? match : `${scheme}${separator}${quote}[REDACTED]`,
+ )
+ .replace(credentialAssignmentPattern, (match, key: string, separator: string, candidate: string) =>
+ isHarmlessCustomWidgetCredentialSetting(candidate) ? match : `${key}${separator}[REDACTED]`,
+ );
+}
+
+function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
+ return (
+ path.length === 3 &&
+ path[2] === "auth" &&
+ (path[0] === "sources" || path[0] === "requests") &&
+ typeof path[1] === "string"
+ );
+}
+
+function hasCredentialFieldValue(value: unknown): boolean {
+ if (value === null || value === undefined || value === "") return false;
+ return true;
+}
+
export function validatePrototypeKeys(value: unknown, ctx: z.RefinementCtx) {
walk(value, [], ctx);
}
diff --git a/packages/custom-widgets/src/core/import.ts b/packages/custom-widgets/src/core/import.ts
index 7e341f303..0de898264 100644
--- a/packages/custom-widgets/src/core/import.ts
+++ b/packages/custom-widgets/src/core/import.ts
@@ -69,10 +69,6 @@ function removedStateIssues(value: Record): CustomWidgetImportI
);
}
-function isTemplatePlaceholder(value: unknown) {
- return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
-}
-
type ParsedWidgetValue =
| { success: true; value: Record }
| { success: false; issue: CustomWidgetImportIssue };
@@ -100,24 +96,10 @@ export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 |
export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlock = blocks.find((block) => block.language === "json" || block.language === "");
- const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx");
const source = jsonBlock?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] };
const widget = { ...parsedValue.value };
- if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) {
- return {
- success: false,
- issues: [
- {
- code: "MISSING_JSX_BLOCK",
- path: ["template"],
- message: "The manifest uses a template placeholder but no fenced JSX block was provided.",
- },
- ],
- };
- }
- if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim();
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
@@ -128,20 +110,17 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === "");
- const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx");
const formatIssues: CustomWidgetImportIssue[] = [];
if (jsonBlocks.length !== 1)
formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." });
- if (jsxBlocks.length !== 1)
- formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." });
+ if (blocks.length !== jsonBlocks.length)
+ formatIssues.push({
+ code: "AI_SINGLE_JSON_BLOCK_REQUIRED",
+ message: "Expected one JSON block only. Put the complete JSX directly in its template field.",
+ });
const source = jsonBlocks[0]?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) {
- if (/\\_/u.test(source))
- formatIssues.push({
- code: "INVALID_JSON_ESCAPE",
- message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".',
- });
if (/"(?:stateSchema|defaultState)"\s*:/u.test(source))
formatIssues.push({
code: "REMOVED_LOCAL_STATE",
@@ -153,15 +132,6 @@ export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResu
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
const warnings: CustomWidgetImportIssue[] = [];
- if (jsxBlocks[0]) {
- if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
- else if (widget.template !== jsxBlocks[0].content.trim())
- formatIssues.push({
- code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED",
- path: ["template"],
- message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.',
- });
- }
if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts
index 10f32169a..7149dcc47 100644
--- a/packages/custom-widgets/src/core/request-schema.ts
+++ b/packages/custom-widgets/src/core/request-schema.ts
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { customWidgetMethods } from "./schema-types";
+import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
@@ -26,10 +27,9 @@ const authSchema = z.union([
export type CustomWidgetSourceUrlIssue = "invalid" | "protocol" | "credentials" | "queryOrFragment";
export function getCustomWidgetSourceUrlIssue(value: string): CustomWidgetSourceUrlIssue | null {
- if (!URL.canParse(value)) return "invalid";
+ const issue = getCustomWidgetHttpUrlIssue(value);
+ if (issue) return issue;
const url = new URL(value);
- if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol";
- if (url.username || url.password) return "credentials";
if (url.search || url.hash) return "queryOrFragment";
return null;
}
@@ -45,12 +45,12 @@ export const customWidgetSourceSchema = z.strictObject({
name: z.string().trim().min(1).max(128).optional(),
baseUrl: z
.string()
- .url()
- .max(2048)
.superRefine((value, ctx) => {
const issue = getCustomWidgetSourceUrlIssue(value);
- if (issue && issue !== "invalid") ctx.addIssue({ code: "custom", message: sourceUrlIssueMessages[issue] });
- }),
+ if (issue) ctx.addIssue({ code: "custom", message: sourceUrlIssueMessages[issue] });
+ })
+ .url()
+ .max(2048),
networkScope: z.enum(customJsxNetworkScopes),
auth: authSchema.default("none"),
});
@@ -157,6 +157,15 @@ export const customJsxRequestSchema = z
confirmation: confirmationSchema.optional(),
invalidates: z.array(customWidgetIdentifierSchema).max(32).optional(),
})
+ .superRefine((request, context) => {
+ if (request.method === "DELETE" && request.kind !== "action") {
+ context.addIssue({
+ code: "custom",
+ path: ["kind"],
+ message: "DELETE requests must be manual actions",
+ });
+ }
+ })
.transform((request) => {
const confirmation =
request.method === "DELETE" && request.confirmation === undefined
diff --git a/packages/custom-widgets/src/core/url-policy.ts b/packages/custom-widgets/src/core/url-policy.ts
new file mode 100644
index 000000000..1cd98a519
--- /dev/null
+++ b/packages/custom-widgets/src/core/url-policy.ts
@@ -0,0 +1,116 @@
+export type CustomWidgetHttpUrlIssue = "invalid" | "protocol" | "credentials";
+
+const forbiddenUnicodeCodePointRanges = [
+ [0x0000, 0x0020],
+ [0x007f, 0x00a0],
+ [0x1680, 0x1680],
+ [0x2000, 0x200f],
+ [0x2028, 0x202f],
+ [0x205f, 0x206f],
+ [0x3000, 0x3000],
+ [0xfeff, 0xfeff],
+] as const;
+
+/**
+ * Applies the conservative URL syntax shared by Homarr and the Workshop
+ * validator. WHATWG URL parsing intentionally accepts and normalizes several
+ * ambiguous spellings (for example IPv4 integers, backslashes and escaped
+ * hostnames). Definitions are persisted and later used for network requests,
+ * so authoring accepts only an unambiguous authority spelling.
+ */
+export function getCustomWidgetHttpUrlIssue(value: string): CustomWidgetHttpUrlIssue | null {
+ if (hasForbiddenUrlCharacter(value) || value.includes("\\")) return "invalid";
+ if (!URL.canParse(value)) return "invalid";
+
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return "invalid";
+ }
+
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol";
+
+ const authority = getRawAuthority(value);
+ if (authority === null) return "invalid";
+ if (authority.includes("@") || url.username || url.password) return "credentials";
+ if (authority.includes("%") || /[。.。]/u.test(authority)) return "invalid";
+
+ const hostAndPort = splitHostAndPort(authority);
+ if (!hostAndPort || !hasCanonicalPort(hostAndPort.port)) return "invalid";
+
+ const rawHostname = withoutTrailingRootDot(hostAndPort.hostname);
+ const parsedHostname = withoutTrailingRootDot(url.hostname);
+ if (!rawHostname || !hasCanonicalParsedHostname(parsedHostname)) return "invalid";
+ if (
+ rawHostname.startsWith("[") &&
+ parsedHostname.startsWith("[") &&
+ !rawHostname.includes(".") &&
+ rawHostname.toLowerCase() !== parsedHostname
+ ) {
+ return "invalid";
+ }
+
+ if (looksNumeric(rawHostname) && !isStrictIpv4(rawHostname)) return "invalid";
+ if (isStrictIpv4(parsedHostname) && rawHostname !== parsedHostname) return "invalid";
+
+ return null;
+}
+
+function getRawAuthority(value: string): string | null {
+ const match = value.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/u);
+ return match?.[1] ?? null;
+}
+
+function splitHostAndPort(authority: string): { hostname: string; port: string } | null {
+ if (authority.startsWith("[")) {
+ const closingBracket = authority.indexOf("]");
+ if (closingBracket < 0) return null;
+ const hostname = authority.slice(0, closingBracket + 1);
+ const suffix = authority.slice(closingBracket + 1);
+ if (suffix && !suffix.startsWith(":")) return null;
+ return { hostname, port: suffix ? suffix.slice(1) : "" };
+ }
+
+ const firstColon = authority.indexOf(":");
+ if (firstColon !== authority.lastIndexOf(":")) return null;
+ return {
+ hostname: firstColon < 0 ? authority : authority.slice(0, firstColon),
+ port: firstColon < 0 ? "" : authority.slice(firstColon + 1),
+ };
+}
+
+function hasCanonicalPort(value: string): boolean {
+ if (!value) return true;
+ if (!/^(?:0|[1-9]\d{0,4})$/u.test(value)) return false;
+ return Number(value) <= 65_535;
+}
+
+function hasCanonicalParsedHostname(value: string): boolean {
+ if (value.startsWith("[") && value.endsWith("]")) return true;
+ if (!value || value.length > 253 || !/^[A-Za-z0-9._-]+$/u.test(value)) return false;
+ return value
+ .split(".")
+ .every((label) => Boolean(label) && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-"));
+}
+
+function looksNumeric(value: string): boolean {
+ const parts = value.split(".");
+ return parts.every((part) => /^(?:\d+|0[xX][0-9A-Fa-f]+)$/u.test(part));
+}
+
+function isStrictIpv4(value: string): boolean {
+ const parts = value.split(".");
+ return parts.length === 4 && parts.every((part) => /^(?:0|[1-9]\d{0,2})$/u.test(part) && Number(part) <= 255);
+}
+
+function withoutTrailingRootDot(value: string): string {
+ return value.endsWith(".") ? value.slice(0, -1) : value;
+}
+
+function hasForbiddenUrlCharacter(value: string): boolean {
+ return [...value].some((character) => {
+ const codePoint = character.codePointAt(0) ?? 0;
+ return forbiddenUnicodeCodePointRanges.some(([minimum, maximum]) => codePoint >= minimum && codePoint <= maximum);
+ });
+}
diff --git a/packages/custom-widgets/src/server/errors.ts b/packages/custom-widgets/src/server/errors.ts
index 550284010..44e86dc6e 100644
--- a/packages/custom-widgets/src/server/errors.ts
+++ b/packages/custom-widgets/src/server/errors.ts
@@ -10,17 +10,19 @@ export type CustomWidgetDomainErrorCode =
export class CustomWidgetDomainError extends Error {
public readonly code: CustomWidgetDomainErrorCode;
+ public readonly reason?: "timeout";
public readonly retryAfterMs?: number;
public constructor(input: {
code: CustomWidgetDomainErrorCode;
message: string;
- cause?: unknown;
+ reason?: "timeout";
retryAfterMs?: number;
}) {
- super(input.message, { cause: input.cause });
+ super(input.message);
this.name = "CustomWidgetDomainError";
this.code = input.code;
+ this.reason = input.reason;
this.retryAfterMs = input.retryAfterMs;
}
}
diff --git a/packages/custom-widgets/src/server/index.ts b/packages/custom-widgets/src/server/index.ts
index d71a0109d..227aa28fa 100644
--- a/packages/custom-widgets/src/server/index.ts
+++ b/packages/custom-widgets/src/server/index.ts
@@ -16,7 +16,12 @@ export {
validateCustomWidgetUrl,
} from "./request-executor";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
-export { CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, CustomWidgetRequestLimiter } from "./request-limits";
+export {
+ CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
+ CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
+ CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
+ CustomWidgetRequestLimiter,
+} from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
export type {
diff --git a/packages/custom-widgets/src/server/network-policy.ts b/packages/custom-widgets/src/server/network-policy.ts
index 985bc5738..39064a7f5 100644
--- a/packages/custom-widgets/src/server/network-policy.ts
+++ b/packages/custom-widgets/src/server/network-policy.ts
@@ -66,6 +66,11 @@ loopbackAddresses.addAddress("::1", "ipv6");
type AddressFamily = 4 | 6;
export type ResolvedAddress = { address: string; family: AddressFamily };
+export type HostResolver = (hostname: string) => Promise;
+export interface ResolveHostOptions {
+ signal?: AbortSignal;
+ resolver?: HostResolver;
+}
type AddressClass = "public" | "private" | "loopback" | "blocked";
const normalizeHostname = (value: string) =>
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
@@ -83,12 +88,19 @@ export function classifyAddress(address: string): AddressClass {
export async function resolveAndValidateHost(
hostname: string,
scope: CustomJsxNetworkScope,
+ options: ResolveHostOptions = {},
): Promise {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized) as AddressFamily | 0;
+ throwIfAborted(options.signal);
const addresses = family
? [{ address: normalized, family }]
- : ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
+ : await abortable(
+ options.resolver?.(normalized) ??
+ (lookup(normalized, { all: true, verbatim: true }) as Promise),
+ options.signal,
+ );
+ throwIfAborted(options.signal);
if (!addresses.length)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
for (const address of addresses) {
@@ -106,6 +118,39 @@ export async function resolveAndValidateHost(
return addresses;
}
+function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) throw signal.reason ?? createAbortError();
+}
+
+function abortable(operation: Promise, signal?: AbortSignal): Promise {
+ if (!signal) return operation;
+ if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
+
+ return new Promise((resolve, reject) => {
+ const onAbort = () => {
+ signal.removeEventListener("abort", onAbort);
+ reject(signal.reason ?? createAbortError());
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ operation.then(
+ (value) => {
+ signal.removeEventListener("abort", onAbort);
+ resolve(value);
+ },
+ (error: unknown) => {
+ signal.removeEventListener("abort", onAbort);
+ reject(error);
+ },
+ );
+ });
+}
+
+function createAbortError(): Error {
+ const error = new Error("Operation aborted");
+ error.name = "AbortError";
+ return error;
+}
+
export function validateCustomWidgetUrl(value: string | URL): URL {
let url: URL;
try {
diff --git a/packages/custom-widgets/src/server/request-dispatcher-lifecycle.ts b/packages/custom-widgets/src/server/request-dispatcher-lifecycle.ts
new file mode 100644
index 000000000..b551c324a
--- /dev/null
+++ b/packages/custom-widgets/src/server/request-dispatcher-lifecycle.ts
@@ -0,0 +1,62 @@
+import type { createPinnedAgent } from "./network-policy";
+
+export const DISPATCHER_CLOSE_GRACE_MS = 1_000;
+
+type CustomWidgetDispatcher = ReturnType;
+
+export async function closeDispatcher(
+ dispatcher: CustomWidgetDispatcher,
+ deadlineSignal: AbortSignal,
+): Promise {
+ const cleanupController = new AbortController();
+ const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS);
+ try {
+ await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal]));
+ return true;
+ } catch (error) {
+ detachDispatcherDestroy(dispatcher);
+ if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false;
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function detachDispatcherDestroy(dispatcher: CustomWidgetDispatcher): void {
+ try {
+ void dispatcher.destroy().catch(() => undefined);
+ } catch {
+ // Cleanup is best effort after its bounded grace period.
+ }
+}
+
+function abortable(operation: Promise, signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ void operation.catch(() => undefined);
+ return Promise.reject(signal.reason ?? createAbortError());
+ }
+
+ return new Promise((resolve, reject) => {
+ const onAbort = () => {
+ signal.removeEventListener("abort", onAbort);
+ reject(signal.reason ?? createAbortError());
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ operation.then(
+ (value) => {
+ signal.removeEventListener("abort", onAbort);
+ resolve(value);
+ },
+ (error: unknown) => {
+ signal.removeEventListener("abort", onAbort);
+ reject(error);
+ },
+ );
+ });
+}
+
+function createAbortError(): Error {
+ const error = new Error("Operation aborted");
+ error.name = "AbortError";
+ return error;
+}
diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts
index 0cb381c71..db7976761 100644
--- a/packages/custom-widgets/src/server/request-executor.ts
+++ b/packages/custom-widgets/src/server/request-executor.ts
@@ -12,6 +12,7 @@ import {
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./network-policy";
+import { closeDispatcher } from "./request-dispatcher-lifecycle";
import { parseResponseBody } from "./response";
export {
@@ -33,6 +34,8 @@ const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
export const MAX_REQUEST_DURATION_MS = 45_000;
+const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
+const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
export interface CustomWidgetAuthConfig {
type: string;
@@ -51,7 +54,7 @@ export interface CustomWidgetHttpRequest {
kind: "query" | "action";
cacheKey?: string;
cacheTtlSeconds?: number;
- logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string }) => void;
+ logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
}
export interface CustomWidgetHttpResponse {
@@ -64,6 +67,10 @@ const cache = new Map>();
let cacheEpoch = 0;
+type RequestHopResult =
+ | { kind: "response"; response: CustomWidgetHttpResponse }
+ | { kind: "redirect"; location: string | null; statusCode: number };
+
async function performRequest(input: CustomWidgetHttpRequest): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
@@ -74,11 +81,19 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise controller.abort(), REQUEST_TIMEOUT_MS);
- let responseData: Awaited>;
+ let result: RequestHopResult | undefined;
+ let lifecycleFailure: { error: unknown } | undefined;
try {
- responseData = await dispatcher.request({
+ const responseData = await dispatcher.request({
origin: currentUrl.origin,
path: `${currentUrl.pathname}${currentUrl.search}`,
method: currentMethod,
@@ -112,54 +128,88 @@ async function performRequestWithinDeadline(
body: currentBody,
signal: AbortSignal.any([deadlineSignal, controller.signal]),
});
- } catch (error) {
- await dispatcher.close();
- if (error instanceof CustomWidgetDomainError) throw error;
- input.logError?.({
- origin: currentUrl.origin,
- method: currentMethod,
- errorName: error instanceof Error ? error.name : "UnknownError",
- });
- throw new CustomWidgetDomainError({
- code: "BAD_GATEWAY",
- message: error instanceof Error ? error.message : "External request failed",
- cause: error,
- });
- } finally {
- clearTimeout(timeout);
- }
- if (![301, 302, 303, 307, 308].includes(responseData.statusCode)) {
- try {
+ if (![301, 302, 303, 307, 308].includes(responseData.statusCode)) {
const body = await responseData.body.arrayBuffer();
const response = new Response(body.byteLength > 0 ? body : null, {
status: responseData.statusCode,
statusText: STATUS_CODES[responseData.statusCode] ?? "",
headers: normalizeResponseHeaders(responseData.headers),
});
- return {
- ok: response.ok,
- status: response.status,
- statusText: response.statusText,
- data: await parseResponseBody(response),
+ result = {
+ kind: "response",
+ response: {
+ ok: response.ok,
+ status: response.status,
+ statusText: response.statusText,
+ data: await parseResponseBody(response),
+ },
};
- } finally {
- await dispatcher.close();
+ } else {
+ await responseData.body.dump();
+ result = {
+ kind: "redirect",
+ location: normalizeResponseHeaders(responseData.headers).get("location"),
+ statusCode: responseData.statusCode,
+ };
+ }
+ } catch (error) {
+ lifecycleFailure = { error };
+ } finally {
+ clearTimeout(timeout);
+ try {
+ const closed = await closeDispatcher(dispatcher, deadlineSignal);
+ if (!closed)
+ input.logError?.({
+ origin: currentUrl.origin,
+ method: currentMethod,
+ errorName: "DispatcherCloseTimeout",
+ });
+ } catch {
+ input.logError?.({
+ origin: currentUrl.origin,
+ method: currentMethod,
+ errorName: "DispatcherCloseError",
+ });
}
}
- try {
- await responseData.body.dump();
- } finally {
- await dispatcher.close();
+
+ if (lifecycleFailure) {
+ const { error } = lifecycleFailure;
+ if (error instanceof CustomWidgetDomainError) throw error;
+ if (error instanceof Error && "code" in error && error.code === RESPONSE_TOO_LARGE_ERROR_CODE) {
+ throw new CustomWidgetDomainError({
+ code: "PAYLOAD_TOO_LARGE",
+ message: "Response exceeds the 1 MiB limit",
+ });
+ }
+ const timedOut = isCustomWidgetRequestTimeoutError(error, deadlineSignal, controller.signal);
+ input.logError?.({
+ origin: currentUrl.origin,
+ method: currentMethod,
+ errorName: timedOut ? "RequestTimeout" : "TransportError",
+ ...(timedOut ? { reason: "timeout" as const } : {}),
+ });
+ throw new CustomWidgetDomainError({
+ code: "BAD_GATEWAY",
+ message: timedOut ? "External request timed out" : "External request failed",
+ ...(timedOut ? { reason: "timeout" as const } : {}),
+ });
}
+
+ if (!result)
+ throw new CustomWidgetDomainError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "External request did not complete",
+ });
+ if (result.kind === "response") return result.response;
if (redirects >= maxRedirects)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
- const location = normalizeResponseHeaders(responseData.headers).get("location");
- if (!location)
+ if (!result.location)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
- const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
+ const redirected = validateCustomWidgetUrl(new URL(result.location, currentUrl));
if (redirected.origin !== baseUrl.origin)
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
- if (responseData.statusCode === 303 || ([301, 302].includes(responseData.statusCode) && currentMethod === "POST")) {
+ if (result.statusCode === 303 || ([301, 302].includes(result.statusCode) && currentMethod === "POST")) {
currentMethod = "GET";
currentBody = undefined;
}
@@ -167,6 +217,14 @@ async function performRequestWithinDeadline(
}
}
+export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean {
+ if (signals.some((signal) => signal.aborted)) return true;
+ if (!(error instanceof Error)) return false;
+ if (error.name.includes("Timeout")) return true;
+ const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
+ return code !== undefined && TIMEOUT_ERROR_CODES.has(code);
+}
+
function normalizeResponseHeaders(values: Record) {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) {
diff --git a/packages/custom-widgets/src/server/request-limits.ts b/packages/custom-widgets/src/server/request-limits.ts
index 8d6d119bc..565d8ee84 100644
--- a/packages/custom-widgets/src/server/request-limits.ts
+++ b/packages/custom-widgets/src/server/request-limits.ts
@@ -7,12 +7,16 @@ const WINDOW_MS = 60_000;
// Keep the lease above that total and identify every acquisition so a late
// release can never remove capacity owned by a newer request.
export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000;
+export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4;
+export const CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT = 8;
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
+const DEFINITION_CATEGORY_LIMITS = { query: 240, action: 40, delete: 12 } as const;
type RequestCategory = keyof typeof CATEGORY_LIMITS;
export interface RequestLimitInput {
category: RequestCategory;
userId?: string;
+ anonymousId?: string;
itemId: string;
definitionId: string;
}
@@ -35,33 +39,45 @@ export class CustomWidgetRequestLimiter {
public constructor(private readonly options: RequestLimiterOptions = {}) {}
public async acquire(input: RequestLimitInput): Promise<() => Promise> {
- const identity = input.userId ?? "anonymous";
+ const identity = input.userId ? `user:${input.userId}` : `anonymous:${input.anonymousId ?? "unknown"}`;
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
+ const definitionRateKey = `custom-widget:rate:v2:definition:${input.category}:${hashKey(input.definitionId)}`;
const userKey = `custom-widget:concurrency:v2:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:v2:definition:${hashKey(input.definitionId)}`;
- if (!this.options.store) return this.acquireLocal(rateKey, userKey, definitionKey, input.category);
+ if (!this.options.store)
+ return this.acquireLocal(rateKey, definitionRateKey, userKey, definitionKey, input.category);
try {
- return await this.acquireStored(rateKey, userKey, definitionKey, input.category, this.options.store);
+ return await this.acquireStored(
+ rateKey,
+ definitionRateKey,
+ userKey,
+ definitionKey,
+ input.category,
+ this.options.store,
+ );
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
this.options.onStoreError?.(error);
throw new CustomWidgetDomainError({
code: "INTERNAL_SERVER_ERROR",
message: "Request limiter is unavailable",
- cause: error,
});
}
}
- private acquireLocal(rateKey: string, userKey: string, definitionKey: string, category: RequestCategory) {
+ private acquireLocal(
+ rateKey: string,
+ definitionRateKey: string,
+ userKey: string,
+ definitionKey: string,
+ category: RequestCategory,
+ ) {
const now = Date.now();
- const recent = (this.rateBuckets.get(rateKey) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
- if (recent.length >= CATEGORY_LIMITS[category]) throwLimit(WINDOW_MS - (now - (recent[0] ?? now)));
- recent.push(now);
- this.rateBuckets.set(rateKey, recent);
- this.acquireLocalConcurrency(userKey, 4);
+ this.acquireLocalRate(rateKey, CATEGORY_LIMITS[category], now);
+ this.acquireLocalRate(definitionRateKey, DEFINITION_CATEGORY_LIMITS[category], now);
+ this.acquireLocalConcurrency(userKey, CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT);
try {
- this.acquireLocalConcurrency(definitionKey, 8);
+ this.acquireLocalConcurrency(definitionKey, CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT);
} catch (error) {
this.releaseLocalConcurrency(userKey);
throw error;
@@ -74,6 +90,7 @@ export class CustomWidgetRequestLimiter {
private async acquireStored(
rateKey: string,
+ definitionRateKey: string,
userKey: string,
definitionKey: string,
category: RequestCategory,
@@ -82,11 +99,27 @@ export class CustomWidgetRequestLimiter {
const ownerId = randomUUID();
const rate = await store.incrementRate(rateKey, WINDOW_MS);
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
- if (!(await store.acquireConcurrency(userKey, ownerId, 4, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
+ const definitionRate = await store.incrementRate(definitionRateKey, WINDOW_MS);
+ if (definitionRate.count > DEFINITION_CATEGORY_LIMITS[category]) throwLimit(definitionRate.retryAfterMs);
+ if (
+ !(await store.acquireConcurrency(
+ userKey,
+ ownerId,
+ CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
+ CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
+ ))
+ ) {
throwLimit(1_000);
}
try {
- if (!(await store.acquireConcurrency(definitionKey, ownerId, 8, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
+ if (
+ !(await store.acquireConcurrency(
+ definitionKey,
+ ownerId,
+ CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
+ CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
+ ))
+ ) {
throwLimit(1_000);
}
} catch (error) {
@@ -98,6 +131,13 @@ export class CustomWidgetRequestLimiter {
};
}
+ private acquireLocalRate(key: string, limit: number, now: number): void {
+ const recent = (this.rateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
+ if (recent.length >= limit) throwLimit(WINDOW_MS - (now - (recent[0] ?? now)));
+ recent.push(now);
+ this.rateBuckets.set(key, recent);
+ }
+
private acquireLocalConcurrency(key: string, limit: number): void {
const count = this.concurrency.get(key) ?? 0;
if (count >= limit) throwLimit(1_000);
diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts
index e41497d77..a949ea592 100644
--- a/packages/custom-widgets/src/test/ai-prompt.spec.ts
+++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts
@@ -18,7 +18,9 @@ describe("AI prompt", () => {
expect(prompt).toContain("Example — Service dashboard");
expect(prompt).toContain("Example — Search and action");
expect(prompt).toContain("visual hierarchy");
- expect(prompt).toContain('template set exactly to "__HOMARR_TEMPLATE__"');
+ expect(prompt).toContain("Put the complete JSX directly in its template string");
+ expect(prompt).toContain("copy one code block and paste it into Homarr once");
+ expect(prompt).not.toContain("fenced block followed by");
expect(prompt).toContain('trigger="manual"');
expect(prompt).toContain("never write `=> {` anywhere");
expect(prompt).toContain('"choicesFrom"');
@@ -45,4 +47,40 @@ describe("AI prompt", () => {
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
expect(prompt.length).toBeLessThanOrEqual(12_000);
});
+
+ it("redacts embedded request credentials while retaining harmless request metadata", () => {
+ const prompt = buildCustomWidgetAiPrompt(undefined, null, {
+ requests: {
+ status: {
+ path: "/status?credential=Bearer-sk-secret-123456",
+ headers: {
+ "X-Auth": "Bearer sk-secret-123456",
+ "X-Service": "Basic dXNlcjpwYXNz",
+ "X-Feature-Key": "dashboard-layout",
+ },
+ },
+ },
+ template: "Bearer ghp_abcdefghijklmnopqrstuvwxyz123456 ",
+ });
+
+ expect(prompt).not.toContain("sk-secret-123456");
+ expect(prompt).not.toContain("dXNlcjpwYXNz");
+ expect(prompt).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz123456");
+ expect(prompt).toContain('"X-Auth": "[REDACTED]"');
+ expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"');
+ });
+
+ it("redacts credentials embedded directly in the free-form request", () => {
+ const prompt = buildCustomWidgetAiPrompt(
+ undefined,
+ null,
+ null,
+ "Use Authorization: Bearer sk-secret-123456 and token=ghp_abcdefghijklmnopqrstuvwxyz123456",
+ );
+
+ expect(prompt).not.toContain("sk-secret-123456");
+ expect(prompt).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz123456");
+ expect(prompt).toContain("Authorization: Bearer [REDACTED]");
+ expect(prompt).toContain("token=[REDACTED]");
+ });
});
diff --git a/packages/custom-widgets/src/test/import.spec.ts b/packages/custom-widgets/src/test/import.spec.ts
index 9637da0f9..11e99b7c0 100644
--- a/packages/custom-widgets/src/test/import.spec.ts
+++ b/packages/custom-widgets/src/test/import.spec.ts
@@ -7,12 +7,11 @@ import {
parseCustomWidgetClipboardDetailed,
} from "../core";
-const response = (manifest: unknown, jsx = "Ready ") =>
- `\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\`\n\`\`\`jsx\n${jsx}\n\`\`\``;
+const response = (manifest: unknown) => `\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\``;
describe("Custom Widget imports", () => {
- it("accepts exactly one lean manifest and JSX block", () => {
- const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" }));
+ it("accepts one complete JSON block and loads its template", () => {
+ const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "Ready " }));
expect(result.success).toBe(true);
if (result.success) expect(result.widget.template).toBe("Ready ");
});
@@ -25,7 +24,7 @@ describe("Custom Widget imports", () => {
requests: [],
optionsSchema: {},
defaultOptions: {},
- template: "__HOMARR_TEMPLATE__",
+ template: "Ready ",
}),
);
expect(result.success).toBe(false);
@@ -34,12 +33,20 @@ describe("Custom Widget imports", () => {
it("reports missing fences and removed local state", () => {
expect(parseCustomWidgetAiResponse(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
- response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "__HOMARR_TEMPLATE__" }),
+ response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "Ready " }),
);
expect(result.success).toBe(false);
if (!result.success) expect(result.issues.some(({ code }) => code === "REMOVED_LOCAL_STATE")).toBe(true);
});
+ it("rejects additional code blocks", () => {
+ const result = parseCustomWidgetAiResponse(
+ `${response({ ...CUSTOM_WIDGET_STARTER, template: "Ready " })}\n\`\`\`jsx\nOld format \n\`\`\``,
+ );
+ expect(result.success).toBe(false);
+ if (!result.success) expect(result.issues.some(({ code }) => code === "AI_SINGLE_JSON_BLOCK_REQUIRED")).toBe(true);
+ });
+
it("returns a capability review from keyed records", () => {
const review = getImportReview(CUSTOM_WIDGET_STARTER);
expect(review).toMatchObject({ origins: ["https://example.com"], authTypes: ["none"], hasActions: false });
diff --git a/packages/custom-widgets/src/test/reference-widgets.spec.ts b/packages/custom-widgets/src/test/reference-widgets.spec.ts
index 0d99a2baf..d8ffbfb30 100644
--- a/packages/custom-widgets/src/test/reference-widgets.spec.ts
+++ b/packages/custom-widgets/src/test/reference-widgets.spec.ts
@@ -13,15 +13,12 @@ describe("reference widget capabilities", () => {
expect(parsed.requests.detail?.trigger).toBe("manual");
});
- it("round-trips the full Pokédex through the exact two-block AI paste format", () => {
+ it("round-trips the full Pokédex through the single-block AI paste format", () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
if (!pokedex) throw new Error("Pokédex seed was not found");
- const { template, ...manifest } = pokedex;
- const parsed = parseCustomWidgetAiResponse(
- `\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
- );
+ const parsed = parseCustomWidgetAiResponse(`\`\`\`json\n${JSON.stringify(pokedex, null, 2)}\n\`\`\``);
expect(parsed.success).toBe(true);
- if (parsed.success) expect(parsed.widget.template).toBe(template);
+ if (parsed.success) expect(parsed.widget.template).toBe(pokedex.template);
});
it("validates a polished Portainer dashboard with start, stop, and restart actions", () => {
@@ -38,10 +35,10 @@ describe("reference widget capabilities", () => {
});
it("round-trips the Portainer dashboard through AI paste", () => {
- const { template, ...manifest } = PORTAINER_REFERENCE_WIDGET;
const parsed = parseCustomWidgetAiResponse(
- `\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
+ `\`\`\`json\n${JSON.stringify(PORTAINER_REFERENCE_WIDGET, null, 2)}\n\`\`\``,
);
expect(parsed.success).toBe(true);
+ if (parsed.success) expect(parsed.widget.template).toBe(PORTAINER_REFERENCE_WIDGET.template);
});
});
diff --git a/packages/custom-widgets/src/test/request-deadline.spec.ts b/packages/custom-widgets/src/test/request-deadline.spec.ts
new file mode 100644
index 000000000..d350705c0
--- /dev/null
+++ b/packages/custom-widgets/src/test/request-deadline.spec.ts
@@ -0,0 +1,209 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type * as NetworkPolicy from "../server/network-policy";
+import type { CustomWidgetHttpRequest } from "../server/request-executor";
+
+const mocks = vi.hoisted(() => ({
+ lookup: vi.fn(),
+ createPinnedAgent: vi.fn(),
+}));
+
+vi.mock("node:dns/promises", () => ({ lookup: mocks.lookup }));
+vi.mock("../server/network-policy", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, createPinnedAgent: mocks.createPinnedAgent };
+});
+
+import { executeCustomWidgetRequest, MAX_REQUEST_DURATION_MS } from "../server/request-executor";
+import { closeDispatcher, DISPATCHER_CLOSE_GRACE_MS } from "../server/request-dispatcher-lifecycle";
+import { resolveAndValidateHost } from "../server/network-policy";
+
+const query = {
+ baseUrl: "https://example.com",
+ method: "GET",
+ networkScope: "public",
+ kind: "query",
+} satisfies CustomWidgetHttpRequest;
+
+describe("custom widget request deadline", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mocks.lookup.mockReset();
+ mocks.createPinnedAgent.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns at the total deadline when DNS resolution stalls", async () => {
+ mocks.lookup.mockReturnValue(new Promise(() => undefined));
+ const assertion = expect(executeCustomWidgetRequest(query)).rejects.toMatchObject({
+ code: "BAD_GATEWAY",
+ reason: "timeout",
+ });
+
+ await vi.advanceTimersByTimeAsync(MAX_REQUEST_DURATION_MS);
+
+ await assertion;
+ expect(mocks.createPinnedAgent).not.toHaveBeenCalled();
+ });
+
+ it("cancels an injected stalled resolver when its deadline aborts", async () => {
+ const controller = new AbortController();
+ const resolver = vi.fn(() => new Promise(() => undefined));
+ const assertion = expect(
+ resolveAndValidateHost("example.com", "public", { signal: controller.signal, resolver }),
+ ).rejects.toMatchObject({ name: "AbortError" });
+
+ controller.abort();
+
+ await assertion;
+ expect(resolver).toHaveBeenCalledWith("example.com");
+ });
+
+ it("detaches stalled dispatcher cleanup after a bounded grace period", async () => {
+ const close = vi.fn(() => new Promise(() => undefined));
+ const destroy = vi.fn(async () => undefined);
+ mocks.createPinnedAgent.mockReturnValue({
+ request: vi.fn(async () => ({
+ statusCode: 200,
+ headers: { "content-type": "application/json" },
+ body: {
+ arrayBuffer: vi.fn(async () => new TextEncoder().encode("{}").buffer),
+ },
+ })),
+ close,
+ destroy,
+ });
+ const logError = vi.fn();
+ const request = executeCustomWidgetRequest({
+ ...query,
+ baseUrl: "http://127.0.0.1",
+ networkScope: "loopback",
+ logError,
+ });
+
+ await vi.advanceTimersByTimeAsync(DISPATCHER_CLOSE_GRACE_MS);
+
+ await expect(request).resolves.toMatchObject({ ok: true, status: 200, data: {} });
+ expect(close).toHaveBeenCalledOnce();
+ expect(destroy).toHaveBeenCalledOnce();
+ expect(logError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ errorName: "DispatcherCloseTimeout",
+ }),
+ );
+ });
+
+ it("does not turn a completed action into a failure when dispatcher cleanup rejects", async () => {
+ const closeFailure = Object.assign(new Error("cleanup failed"), { name: "DispatcherCloseError" });
+ const close = vi.fn(async () => {
+ throw closeFailure;
+ });
+ const destroy = vi.fn(async () => undefined);
+ mocks.createPinnedAgent.mockReturnValue({
+ request: vi.fn(async () => ({
+ statusCode: 200,
+ headers: { "content-type": "application/json" },
+ body: {
+ arrayBuffer: vi.fn(async () => new TextEncoder().encode('{"updated":true}').buffer),
+ },
+ })),
+ close,
+ destroy,
+ });
+ const logError = vi.fn();
+
+ await expect(
+ executeCustomWidgetRequest({
+ ...query,
+ baseUrl: "http://127.0.0.1",
+ method: "POST",
+ networkScope: "loopback",
+ kind: "action",
+ logError,
+ }),
+ ).resolves.toMatchObject({ ok: true, status: 200, data: { updated: true } });
+ expect(close).toHaveBeenCalledOnce();
+ expect(destroy).toHaveBeenCalledOnce();
+ expect(logError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ errorName: "DispatcherCloseError",
+ }),
+ );
+ });
+
+ it("consumes a late close rejection when cleanup starts after the deadline", async () => {
+ const abortReason = new Error("request deadline elapsed");
+ const closeFailure = new Error("late close failure");
+ const controller = new AbortController();
+ controller.abort(abortReason);
+ let rejectClose: ((reason: Error) => void) | undefined;
+ const close = vi.fn(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectClose = reject;
+ }),
+ );
+ const destroy = vi.fn(async () => undefined);
+
+ await expect(closeDispatcher({ close, destroy } as never, controller.signal)).rejects.toBe(abortReason);
+ rejectClose?.(closeFailure);
+ await Promise.resolve();
+
+ expect(close).toHaveBeenCalledOnce();
+ expect(destroy).toHaveBeenCalledOnce();
+ });
+
+ it("redacts apiKeyQuery credentials from transport errors and structured log events", async () => {
+ const apiKey = "api-key-query-secret";
+ const request = vi.fn(async ({ path }: { path: string }) => {
+ const failure = new Error(`Transport failed for https://example.com${path}`);
+ failure.name = `SocketError:${path}`;
+ throw failure;
+ });
+ mocks.createPinnedAgent.mockReturnValue({
+ request,
+ close: vi.fn(async () => undefined),
+ destroy: vi.fn(async () => undefined),
+ });
+ const logError = vi.fn();
+
+ const error = await executeCustomWidgetRequest({
+ ...query,
+ baseUrl: "http://127.0.0.1",
+ targetUrl: "http://127.0.0.1/status?view=summary",
+ networkScope: "loopback",
+ auth: {
+ type: "apiKeyQuery",
+ headerName: "access_token",
+ secrets: [{ kind: "apiKey", value: apiKey }],
+ },
+ logError,
+ }).then(
+ () => undefined,
+ (cause: unknown) => cause,
+ );
+
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ path: expect.stringContaining(`access_token=${apiKey}`),
+ }),
+ );
+ expect(error).toMatchObject({
+ code: "BAD_GATEWAY",
+ message: "External request failed",
+ });
+ expect((error as Error).cause).toBeUndefined();
+ expect(String(error)).not.toContain(apiKey);
+ expect(String(error)).not.toContain("access_token");
+ expect(logError).toHaveBeenCalledWith({
+ origin: "http://127.0.0.1",
+ method: "GET",
+ errorName: "TransportError",
+ });
+ expect(JSON.stringify({ error, logCalls: logError.mock.calls })).not.toContain(apiKey);
+ expect(JSON.stringify({ error, logCalls: logError.mock.calls })).not.toContain("access_token");
+ });
+});
diff --git a/packages/custom-widgets/src/test/request-limits.spec.ts b/packages/custom-widgets/src/test/request-limits.spec.ts
index 7f7fd02e8..37ded3137 100644
--- a/packages/custom-widgets/src/test/request-limits.spec.ts
+++ b/packages/custom-widgets/src/test/request-limits.spec.ts
@@ -23,6 +23,51 @@ describe("custom widget request limits", () => {
await Promise.all(releases.slice(1).map((release) => release()));
});
+ it("keeps anonymous client concurrency buckets independent", async () => {
+ const limiter = new CustomWidgetRequestLimiter();
+ const firstClient = { ...input, userId: undefined, anonymousId: "198.51.100.10" };
+ const secondClient = { ...input, userId: undefined, anonymousId: "198.51.100.11" };
+ const firstClientReleases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(firstClient)));
+
+ await expect(limiter.acquire(firstClient)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
+ const releaseSecondClient = await limiter.acquire(secondClient);
+
+ await releaseSecondClient();
+ await Promise.all(firstClientReleases.map((release) => release()));
+ });
+
+ it("enforces an identity-independent per-definition rate bucket", async () => {
+ const anonymousQuery = { ...input, category: "query" as const, userId: undefined };
+ let increment = 0;
+ const store: RequestLimitStore = {
+ incrementRate: vi.fn(async () => {
+ increment += 1;
+ return {
+ // Each acquisition increments its identity bucket first and its
+ // definition bucket second. Simulate a fresh forged identity while
+ // the shared definition bucket is already exhausted.
+ count: increment === 4 ? 241 : 1,
+ retryAfterMs: 456,
+ };
+ }),
+ acquireConcurrency: vi.fn(async () => true),
+ releaseConcurrency: vi.fn(async () => undefined),
+ };
+ const limiter = new CustomWidgetRequestLimiter({ store });
+ const release = await limiter.acquire({ ...anonymousQuery, anonymousId: "198.51.100.10" });
+ await release();
+
+ await expect(limiter.acquire({ ...anonymousQuery, anonymousId: "203.0.113.20" })).rejects.toMatchObject({
+ code: "TOO_MANY_REQUESTS",
+ retryAfterMs: 456,
+ });
+
+ const incrementRate = vi.mocked(store.incrementRate);
+ expect(incrementRate.mock.calls[0]?.[0]).not.toBe(incrementRate.mock.calls[2]?.[0]);
+ expect(incrementRate.mock.calls[1]?.[0]).toBe(incrementRate.mock.calls[3]?.[0]);
+ expect(store.acquireConcurrency).toHaveBeenCalledTimes(2);
+ });
+
it("enforces category limits and exposes retry metadata", async () => {
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 11, retryAfterMs: 321 })),
diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts
index 884e8a32f..eeb02a262 100644
--- a/packages/custom-widgets/src/test/schema.spec.ts
+++ b/packages/custom-widgets/src/test/schema.spec.ts
@@ -25,12 +25,20 @@ describe("lean Custom Widget schema", () => {
trigger: "manual",
permission: "modify",
});
- expect(customJsxRequestSchema.parse({ method: "DELETE", path: "/item" })).toMatchObject({
+ expect(customJsxRequestSchema.parse({ kind: "action", method: "DELETE", path: "/item" })).toMatchObject({
+ trigger: "manual",
permission: "full",
confirmation: { destructive: true },
});
});
+ it("rejects DELETE requests that could execute as automatic queries", () => {
+ expect(customJsxRequestSchema.safeParse({ method: "DELETE", path: "/item" }).success).toBe(false);
+ expect(
+ customJsxRequestSchema.safeParse({ kind: "query", trigger: "manual", method: "DELETE", path: "/item" }).success,
+ ).toBe(false);
+ });
+
it("validates keyed sources, requests and options", () => {
const result = customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
@@ -93,6 +101,82 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
});
+ it.each([
+ "http://127.1",
+ "http://2130706433",
+ "http://0x7f000001",
+ "http://0177.0.0.1",
+ "http://1.2.3",
+ "http://%31%32%37.0.0.1",
+ "https://%65xample.com",
+ "https://example%2ecom",
+ "https://example.com\\@attacker.invalid",
+ "https:\\\\example.com\\api",
+ " https://example.com",
+ "https://exa\tmple.com",
+ "https://example.com\n",
+ "https://example.com\u00a0",
+ "https://@example.com",
+ "https://:@example.com",
+ "https://example。com",
+ "https://127.0.0.1",
+ "https://-example.com",
+ "https://example-.com",
+ "https://example..com",
+ "https://example.com:00080",
+ "https://example.com:0000000065535",
+ "https://example.com:65536",
+ "https://[fe80::1%25eth0]",
+ "https://[::ffff:192.168.001.001]",
+ "https://[0:0:0:0:0:0:0:1]",
+ "https://[2001:0db8::1]",
+ ])("rejects ambiguous persisted URL spelling %s for sources and icons", (url) => {
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ sources: {
+ default: { ...CUSTOM_WIDGET_STARTER.sources.default, baseUrl: url },
+ },
+ }).success,
+ ).toBe(false);
+ expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, iconUrl: url }).success).toBe(false);
+ });
+
+ it.each([
+ "https://example.com",
+ "https://example.com.",
+ "https://münich.example",
+ "https://xn--mnich-kva.example",
+ "http://192.168.1.1:0",
+ "https://[2001:db8::1]:65535",
+ "https://[::ffff:192.168.1.1]",
+ ])("accepts unambiguous HTTP URLs %s for sources and icons", (url) => {
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ sources: {
+ default: { ...CUSTOM_WIDGET_STARTER.sources.default, baseUrl: url },
+ },
+ }).success,
+ ).toBe(true);
+ expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, iconUrl: url }).success).toBe(true);
+ });
+
+ it("allows ordinary encoded icon paths and query values while rejecting oversized URLs", () => {
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ iconUrl: "https://example.com/icons/custom%20widget.svg?theme=dark#preview",
+ }).success,
+ ).toBe(true);
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ iconUrl: `https://example.com/${"a".repeat(2048)}`,
+ }).success,
+ ).toBe(false);
+ });
+
it("keeps auth secrets outside the document", () => {
const definition = customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
@@ -106,6 +190,71 @@ describe("lean Custom Widget schema", () => {
).toEqual(["default:apiKey", "admin:username", "admin:password"]);
});
+ it("rejects credentials embedded in request paths, headers, and JSX", () => {
+ const requests = [
+ {
+ status: {
+ path: "/api/status?credential=Bearer-sk-secret-123456",
+ },
+ },
+ {
+ status: {
+ path: "/api/status",
+ headers: { "X-Auth": "Bearer sk-secret-123456" },
+ },
+ },
+ {
+ status: {
+ path: "/api/status",
+ headers: { "X-Service": "Basic dXNlcjpwYXNz" },
+ },
+ },
+ {
+ status: {
+ path: "/api/status",
+ headers: { "X-Service": "ghp_abcdefghijklmnopqrstuvwxyz123456" },
+ },
+ },
+ ];
+ for (const candidate of requests) {
+ expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, requests: candidate }).success).toBe(
+ false,
+ );
+ }
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ template: "Bearer sk-secret-123456 ",
+ }).success,
+ ).toBe(false);
+ });
+
+ it("allows harmless key fields, auth modes, headers, and authentication guidance", () => {
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ sources: {
+ default: {
+ ...CUSTOM_WIDGET_STARTER.sources.default,
+ auth: { type: "apiKeyHeader", name: "X-Api-Key" },
+ },
+ },
+ requests: {
+ status: {
+ path: "/api/status?key=status&auth=none",
+ headers: {
+ Accept: "application/json",
+ "X-Api-Version": "2026-07",
+ "X-Feature-Key": "dashboard-layout",
+ },
+ body: { authenticationMode: "none", key: "status", tokenCount: 3 },
+ },
+ },
+ template: "Bearer authentication uses source credentials ",
+ }).success,
+ ).toBe(true);
+ });
+
it("requires installation confirmation for private and placeholder source URLs", () => {
const sources = {
default: { baseUrl: "https://pokeapi.co/api/v2", networkScope: "public" as const, auth: "none" as const },
diff --git a/packages/custom-widgets/src/test/server-policy.spec.ts b/packages/custom-widgets/src/test/server-policy.spec.ts
index b55beef91..c7e25d13b 100644
--- a/packages/custom-widgets/src/test/server-policy.spec.ts
+++ b/packages/custom-widgets/src/test/server-policy.spec.ts
@@ -1,4 +1,5 @@
import { createServer } from "node:http";
+import { errors } from "undici";
import { describe, expect, test } from "vitest";
import {
@@ -6,11 +7,11 @@ import {
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
- invalidateCustomWidgetResponseCache,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "../server";
+import { isCustomWidgetRequestTimeoutError } from "../server/request-executor";
describe("custom widget network policy", () => {
test.each([
@@ -53,6 +54,121 @@ describe("custom widget network policy", () => {
}
});
+ test.each([
+ ["connect", new errors.ConnectTimeoutError()],
+ ["headers", new errors.HeadersTimeoutError()],
+ ["body", new errors.BodyTimeoutError()],
+ ["generic timeout name", Object.assign(new Error("timed out"), { name: "ProxyTimeoutError" })],
+ ])("recognizes %s timeout errors from the complete request lifecycle", (_label, error) => {
+ expect(isCustomWidgetRequestTimeoutError(error)).toBe(true);
+ });
+
+ test("recognizes an aborted request deadline as a timeout", () => {
+ const controller = new AbortController();
+ controller.abort();
+ expect(isCustomWidgetRequestTimeoutError(new errors.RequestAbortedError(), controller.signal)).toBe(true);
+ expect(isCustomWidgetRequestTimeoutError(new Error("socket failed"))).toBe(false);
+ });
+
+ test("normalizes response-body failures after headers arrive", async () => {
+ const events: Array<{ errorName: string; reason?: "timeout" }> = [];
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.flushHeaders();
+ response.write('{"status":');
+ setImmediate(() => response.destroy());
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
+ try {
+ await expect(
+ executeCustomWidgetRequest({
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ method: "GET",
+ networkScope: "loopback",
+ kind: "query",
+ logError: (event) => events.push(event),
+ }),
+ ).rejects.toMatchObject({ code: "BAD_GATEWAY" });
+ expect(events).toHaveLength(1);
+ expect(events[0]?.errorName).toBe("TransportError");
+ } finally {
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
+ }
+ });
+
+ test("normalizes failures while draining a redirect body", async () => {
+ const server = createServer((_request, response) => {
+ response.writeHead(302, { location: "/target" });
+ response.flushHeaders();
+ response.write("partial redirect body");
+ setImmediate(() => response.destroy());
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
+ try {
+ await expect(
+ executeCustomWidgetRequest({
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ method: "GET",
+ networkScope: "loopback",
+ kind: "query",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_GATEWAY" });
+ } finally {
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
+ }
+ });
+
+ test("maps Undici's response-size failure to the domain size limit", async () => {
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(`"${"x".repeat(1024 * 1024)}"`);
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
+ try {
+ await expect(
+ executeCustomWidgetRequest({
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ method: "GET",
+ networkScope: "loopback",
+ kind: "query",
+ }),
+ ).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" });
+ } finally {
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
+ }
+ });
+
+ test("preserves domain errors raised while parsing the response body", async () => {
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end("{invalid");
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
+ try {
+ await expect(
+ executeCustomWidgetRequest({
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ method: "GET",
+ networkScope: "loopback",
+ kind: "query",
+ }),
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: "Upstream returned invalid JSON",
+ });
+ } finally {
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
+ }
+ });
+
test("preserves structured bodies for GET queries", async () => {
const server = createServer((request, response) => {
let body = "";
@@ -164,36 +280,6 @@ describe("custom widget network policy", () => {
}
});
- test("evicts declared response-cache prefixes after an action", async () => {
- let calls = 0;
- const server = createServer((_request, response) => {
- calls += 1;
- response.writeHead(200, { "content-type": "application/json" });
- response.end(JSON.stringify({ calls }));
- });
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
- const address = server.address();
- if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
- const cacheKey = "custom-jsx:item:status:params";
- const input = {
- baseUrl: `http://127.0.0.1:${address.port}`,
- method: "GET" as const,
- networkScope: "loopback" as const,
- kind: "query" as const,
- cacheKey,
- cacheTtlSeconds: 60,
- };
- try {
- await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 1 } });
- await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 1 } });
- invalidateCustomWidgetResponseCache(["custom-jsx:item:status:"]);
- await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 2 } });
- } finally {
- invalidateCustomWidgetResponseCache(["custom-jsx:item:status:"]);
- await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
- }
- });
-
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("HTTP and HTTPS");
diff --git a/packages/db/migrations/mysql/0044_custom_widget_v2_reset.sql b/packages/db/migrations/mysql/0044_custom_widget_v2_reset.sql
deleted file mode 100644
index b717d7b81..000000000
--- a/packages/db/migrations/mysql/0044_custom_widget_v2_reset.sql
+++ /dev/null
@@ -1,30 +0,0 @@
-RENAME TABLE `custom_widget_definition` TO `legacy_custom_widget_definition`, `custom_widget_secret` TO `legacy_custom_widget_secret`;--> statement-breakpoint
-ALTER TABLE `legacy_custom_widget_definition` DROP FOREIGN KEY `custom_widget_definition_creator_id_user_id_fk`;--> statement-breakpoint
-ALTER TABLE `legacy_custom_widget_definition` ADD CONSTRAINT `legacy_cw_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
-ALTER TABLE `legacy_custom_widget_secret` DROP FOREIGN KEY `cw_secret_definition_id_cw_definition_id_fk`;--> statement-breakpoint
-ALTER TABLE `legacy_custom_widget_secret` ADD CONSTRAINT `legacy_cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `legacy_custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-CREATE TABLE `custom_widget_definition` (
- `id` varchar(64) NOT NULL,
- `name` varchar(256) NOT NULL,
- `description` text,
- `icon_url` text,
- `sources` text NOT NULL,
- `requests` text NOT NULL,
- `options` text NOT NULL,
- `template` text NOT NULL,
- `enabled` boolean DEFAULT true NOT NULL,
- `created_at` timestamp DEFAULT (now()) NOT NULL,
- `updated_at` timestamp DEFAULT (now()) NOT NULL,
- `creator_id` varchar(64),
- CONSTRAINT `custom_widget_definition_id` PRIMARY KEY(`id`)
-);--> statement-breakpoint
-CREATE TABLE `custom_widget_secret` (
- `source_id` varchar(64) NOT NULL,
- `kind` varchar(64) NOT NULL,
- `encrypted_value` text NOT NULL,
- `updated_at` timestamp NOT NULL,
- `definition_id` varchar(64) NOT NULL,
- CONSTRAINT `custom_widget_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
-);--> statement-breakpoint
-ALTER TABLE `custom_widget_definition` ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
-ALTER TABLE `custom_widget_secret` ADD CONSTRAINT `cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;
diff --git a/packages/db/migrations/mysql/0044_custom_widget_v2_tables.sql b/packages/db/migrations/mysql/0044_custom_widget_v2_tables.sql
new file mode 100644
index 000000000..629cbbc27
--- /dev/null
+++ b/packages/db/migrations/mysql/0044_custom_widget_v2_tables.sql
@@ -0,0 +1,26 @@
+CREATE TABLE `custom_widget_v2_definition` (
+ `id` varchar(64) NOT NULL,
+ `name` varchar(256) NOT NULL,
+ `description` text,
+ `icon_url` text,
+ `sources` text NOT NULL,
+ `requests` text NOT NULL,
+ `options` text NOT NULL,
+ `template` text NOT NULL,
+ `enabled` boolean DEFAULT true NOT NULL,
+ `created_at` timestamp DEFAULT (now()) NOT NULL,
+ `updated_at` timestamp DEFAULT (now()) NOT NULL,
+ `creator_id` varchar(64),
+ CONSTRAINT `custom_widget_v2_definition_id` PRIMARY KEY(`id`)
+);--> statement-breakpoint
+CREATE TABLE `custom_widget_v2_secret` (
+ `source_id` varchar(64) NOT NULL,
+ `kind` varchar(64) NOT NULL,
+ `encrypted_value` text NOT NULL,
+ `updated_at` timestamp NOT NULL,
+ `definition_id` varchar(64) NOT NULL,
+ CONSTRAINT `custom_widget_v2_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
+);--> statement-breakpoint
+ALTER TABLE `custom_widget_v2_definition` ADD CONSTRAINT `custom_widget_v2_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE `custom_widget_v2_secret` ADD CONSTRAINT `custom_widget_v2_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
diff --git a/packages/db/migrations/mysql/meta/0044_snapshot.json b/packages/db/migrations/mysql/meta/0044_snapshot.json
index 66b344f2c..e9fd750e8 100644
--- a/packages/db/migrations/mysql/meta/0044_snapshot.json
+++ b/packages/db/migrations/mysql/meta/0044_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "mysql",
- "id": "1a10d79c-6c82-4036-8717-0283ebaece10",
+ "id": "e2f11bb0-59e9-4fab-8ae1-5bf764795c74",
"prevId": "073d408e-ff80-4a57-8007-f1f32da1c71c",
"tables": {
"account": {
@@ -534,198 +534,8 @@
"uniqueConstraints": {},
"checkConstraint": {}
},
- "legacy_custom_widget_definition": {
- "name": "legacy_custom_widget_definition",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(256)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "icon_url": {
- "name": "icon_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "auth_type": {
- "name": "auth_type",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'none'"
- },
- "header_name": {
- "name": "header_name",
- "type": "varchar(256)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "method": {
- "name": "method",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'GET'"
- },
- "request_body": {
- "name": "request_body",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "display_type": {
- "name": "display_type",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'singleValue'"
- },
- "display_config": {
- "name": "display_config",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "('{\"json\": {}}')"
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "creator_id": {
- "name": "creator_id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "url": {
- "name": "url",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "enabled": {
- "name": "enabled",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "legacy_cw_definition_creator_id_user_id_fk": {
- "name": "legacy_cw_definition_creator_id_user_id_fk",
- "tableFrom": "legacy_custom_widget_definition",
- "tableTo": "user",
- "columnsFrom": ["creator_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "legacy_custom_widget_definition_id": {
- "name": "legacy_custom_widget_definition_id",
- "columns": ["id"]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "legacy_custom_widget_secret": {
- "name": "legacy_custom_widget_secret",
- "columns": {
- "kind": {
- "name": "kind",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "definition_id": {
- "name": "definition_id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "legacy_cw_secret_definition_id_fk": {
- "name": "legacy_cw_secret_definition_id_fk",
- "tableFrom": "legacy_custom_widget_secret",
- "tableTo": "legacy_custom_widget_definition",
- "columnsFrom": ["definition_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "legacy_custom_widget_secret_definition_id_kind_pk": {
- "name": "legacy_custom_widget_secret_definition_id_kind_pk",
- "columns": ["definition_id", "kind"]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "custom_widget_definition": {
- "name": "custom_widget_definition",
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
"columns": {
"id": {
"name": "id",
@@ -817,9 +627,9 @@
},
"indexes": {},
"foreignKeys": {
- "custom_widget_definition_creator_id_user_id_fk": {
- "name": "custom_widget_definition_creator_id_user_id_fk",
- "tableFrom": "custom_widget_definition",
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
@@ -828,16 +638,16 @@
}
},
"compositePrimaryKeys": {
- "custom_widget_definition_id": {
- "name": "custom_widget_definition_id",
+ "custom_widget_v2_definition_id": {
+ "name": "custom_widget_v2_definition_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
- "custom_widget_secret": {
- "name": "custom_widget_secret",
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
"columns": {
"source_id": {
"name": "source_id",
@@ -877,10 +687,10 @@
},
"indexes": {},
"foreignKeys": {
- "cw_secret_definition_id_fk": {
- "name": "cw_secret_definition_id_fk",
- "tableFrom": "custom_widget_secret",
- "tableTo": "custom_widget_definition",
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
@@ -888,8 +698,8 @@
}
},
"compositePrimaryKeys": {
- "custom_widget_secret_definition_id_source_id_kind_pk": {
- "name": "custom_widget_secret_definition_id_source_id_kind_pk",
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
"columns": ["definition_id", "source_id", "kind"]
}
},
@@ -1718,6 +1528,196 @@
"uniqueConstraints": {},
"checkConstraint": {}
},
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_definition_id": {
+ "name": "custom_widget_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "cw_secret_definition_id_cw_definition_id_fk": {
+ "name": "cw_secret_definition_id_cw_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
"media": {
"name": "media",
"columns": {
diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json
index aa777ce2e..9b608ae05 100644
--- a/packages/db/migrations/mysql/meta/_journal.json
+++ b/packages/db/migrations/mysql/meta/_journal.json
@@ -314,7 +314,7 @@
"idx": 44,
"version": "5",
"when": 1784305537309,
- "tag": "0044_custom_widget_v2_reset",
+ "tag": "0044_custom_widget_v2_tables",
"breakpoints": true
}
]
diff --git a/packages/db/migrations/postgresql/0010_custom_widget_v2_reset.sql b/packages/db/migrations/postgresql/0010_custom_widget_v2_reset.sql
deleted file mode 100644
index 7b36b6468..000000000
--- a/packages/db/migrations/postgresql/0010_custom_widget_v2_reset.sql
+++ /dev/null
@@ -1,29 +0,0 @@
-ALTER TABLE "custom_widget_definition" RENAME TO "legacy_custom_widget_definition";--> statement-breakpoint
-ALTER TABLE "custom_widget_secret" RENAME TO "legacy_custom_widget_secret";--> statement-breakpoint
-ALTER TABLE "legacy_custom_widget_definition" RENAME CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" TO "legacy_cw_definition_creator_id_user_id_fk";--> statement-breakpoint
-ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk" TO "legacy_cw_secret_definition_id_fk";--> statement-breakpoint
-ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_kind_pk" TO "legacy_custom_widget_secret_definition_id_kind_pk";--> statement-breakpoint
-CREATE TABLE "custom_widget_definition" (
- "id" varchar(64) PRIMARY KEY NOT NULL,
- "name" varchar(256) NOT NULL,
- "description" text,
- "icon_url" text,
- "sources" text NOT NULL,
- "requests" text NOT NULL,
- "options" text NOT NULL,
- "template" text NOT NULL,
- "enabled" boolean DEFAULT true NOT NULL,
- "created_at" timestamp DEFAULT now() NOT NULL,
- "updated_at" timestamp DEFAULT now() NOT NULL,
- "creator_id" varchar(64)
-);--> statement-breakpoint
-CREATE TABLE "custom_widget_secret" (
- "source_id" varchar(64) NOT NULL,
- "kind" varchar(64) NOT NULL,
- "encrypted_value" text NOT NULL,
- "updated_at" timestamp NOT NULL,
- "definition_id" varchar(64) NOT NULL,
- CONSTRAINT "custom_widget_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
-);--> statement-breakpoint
-ALTER TABLE "custom_widget_definition" ADD CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
-ALTER TABLE "custom_widget_secret" ADD CONSTRAINT "cw_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_definition"("id") ON DELETE cascade ON UPDATE no action;
diff --git a/packages/db/migrations/postgresql/0010_custom_widget_v2_tables.sql b/packages/db/migrations/postgresql/0010_custom_widget_v2_tables.sql
new file mode 100644
index 000000000..e209f1b97
--- /dev/null
+++ b/packages/db/migrations/postgresql/0010_custom_widget_v2_tables.sql
@@ -0,0 +1,25 @@
+CREATE TABLE "custom_widget_v2_definition" (
+ "id" varchar(64) PRIMARY KEY NOT NULL,
+ "name" varchar(256) NOT NULL,
+ "description" text,
+ "icon_url" text,
+ "sources" text NOT NULL,
+ "requests" text NOT NULL,
+ "options" text NOT NULL,
+ "template" text NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ "creator_id" varchar(64)
+);--> statement-breakpoint
+CREATE TABLE "custom_widget_v2_secret" (
+ "source_id" varchar(64) NOT NULL,
+ "kind" varchar(64) NOT NULL,
+ "encrypted_value" text NOT NULL,
+ "updated_at" timestamp NOT NULL,
+ "definition_id" varchar(64) NOT NULL,
+ CONSTRAINT "custom_widget_v2_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
+);--> statement-breakpoint
+ALTER TABLE "custom_widget_v2_definition" ADD CONSTRAINT "custom_widget_v2_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "custom_widget_v2_secret" ADD CONSTRAINT "custom_widget_v2_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_v2_definition"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+DELETE FROM "groupPermission" WHERE "permission" IN ('custom-widget-manage', 'custom-widget-secret-write');
diff --git a/packages/db/migrations/postgresql/meta/0010_snapshot.json b/packages/db/migrations/postgresql/meta/0010_snapshot.json
index d95510800..0a1d58b0d 100644
--- a/packages/db/migrations/postgresql/meta/0010_snapshot.json
+++ b/packages/db/migrations/postgresql/meta/0010_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "f2cd8146-b478-4617-870e-f601fb437d15",
+ "id": "99938d86-5059-41b3-a21c-ac767440ed00",
"prevId": "97bda61f-0a71-4e41-8e60-874aafe17115",
"version": "7",
"dialect": "postgresql",
@@ -498,180 +498,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
- "public.legacy_custom_widget_definition": {
- "name": "legacy_custom_widget_definition",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(64)",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "varchar(256)",
- "primaryKey": false,
- "notNull": true
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "icon_url": {
- "name": "icon_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "url": {
- "name": "url",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "auth_type": {
- "name": "auth_type",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "default": "'none'"
- },
- "header_name": {
- "name": "header_name",
- "type": "varchar(256)",
- "primaryKey": false,
- "notNull": false
- },
- "method": {
- "name": "method",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "default": "'GET'"
- },
- "request_body": {
- "name": "request_body",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "display_type": {
- "name": "display_type",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "default": "'singleValue'"
- },
- "display_config": {
- "name": "display_config",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "default": "'{\"json\": {}}'"
- },
- "enabled": {
- "name": "enabled",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "default": "now()"
- },
- "creator_id": {
- "name": "creator_id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "legacy_cw_definition_creator_id_user_id_fk": {
- "name": "legacy_cw_definition_creator_id_user_id_fk",
- "tableFrom": "legacy_custom_widget_definition",
- "tableTo": "user",
- "columnsFrom": ["creator_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.legacy_custom_widget_secret": {
- "name": "legacy_custom_widget_secret",
- "schema": "",
- "columns": {
- "kind": {
- "name": "kind",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "definition_id": {
- "name": "definition_id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "legacy_cw_secret_definition_id_fk": {
- "name": "legacy_cw_secret_definition_id_fk",
- "tableFrom": "legacy_custom_widget_secret",
- "tableTo": "legacy_custom_widget_definition",
- "columnsFrom": ["definition_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "legacy_custom_widget_secret_definition_id_kind_pk": {
- "name": "legacy_custom_widget_secret_definition_id_kind_pk",
- "columns": ["definition_id", "kind"]
- }
- },
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": false
- },
- "public.custom_widget_definition": {
- "name": "custom_widget_definition",
+ "public.custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
"schema": "",
"columns": {
"id": {
@@ -752,9 +580,9 @@
},
"indexes": {},
"foreignKeys": {
- "custom_widget_definition_creator_id_user_id_fk": {
- "name": "custom_widget_definition_creator_id_user_id_fk",
- "tableFrom": "custom_widget_definition",
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
@@ -768,8 +596,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
- "public.custom_widget_secret": {
- "name": "custom_widget_secret",
+ "public.custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
"schema": "",
"columns": {
"source_id": {
@@ -805,10 +633,10 @@
},
"indexes": {},
"foreignKeys": {
- "cw_secret_definition_id_fk": {
- "name": "cw_secret_definition_id_fk",
- "tableFrom": "custom_widget_secret",
- "tableTo": "custom_widget_definition",
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
@@ -816,8 +644,8 @@
}
},
"compositePrimaryKeys": {
- "custom_widget_secret_definition_id_source_id_kind_pk": {
- "name": "custom_widget_secret_definition_id_source_id_kind_pk",
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
"columns": ["definition_id", "source_id", "kind"]
}
},
@@ -1632,6 +1460,178 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.media": {
"name": "media",
"schema": "",
diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json
index 87247df05..42b4e6a2e 100644
--- a/packages/db/migrations/postgresql/meta/_journal.json
+++ b/packages/db/migrations/postgresql/meta/_journal.json
@@ -76,7 +76,7 @@
"idx": 10,
"version": "7",
"when": 1784305412862,
- "tag": "0010_custom_widget_v2_reset",
+ "tag": "0010_custom_widget_v2_tables",
"breakpoints": true
}
]
diff --git a/packages/db/migrations/sqlite/0042_custom_widget_v2_reset.sql b/packages/db/migrations/sqlite/0042_custom_widget_v2_tables.sql
similarity index 60%
rename from packages/db/migrations/sqlite/0042_custom_widget_v2_reset.sql
rename to packages/db/migrations/sqlite/0042_custom_widget_v2_tables.sql
index b28b18b1c..da2e3b558 100644
--- a/packages/db/migrations/sqlite/0042_custom_widget_v2_reset.sql
+++ b/packages/db/migrations/sqlite/0042_custom_widget_v2_tables.sql
@@ -1,7 +1,4 @@
-PRAGMA foreign_keys=OFF;--> statement-breakpoint
-ALTER TABLE `custom_widget_definition` RENAME TO `legacy_custom_widget_definition`;--> statement-breakpoint
-ALTER TABLE `custom_widget_secret` RENAME TO `legacy_custom_widget_secret`;--> statement-breakpoint
-CREATE TABLE `custom_widget_definition` (
+CREATE TABLE `custom_widget_v2_definition` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
@@ -16,13 +13,13 @@ CREATE TABLE `custom_widget_definition` (
`creator_id` text,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
-CREATE TABLE `custom_widget_secret` (
+CREATE TABLE `custom_widget_v2_secret` (
`source_id` text NOT NULL,
`kind` text NOT NULL,
`encrypted_value` text NOT NULL,
`updated_at` integer NOT NULL,
`definition_id` text NOT NULL,
PRIMARY KEY(`definition_id`, `source_id`, `kind`),
- FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON UPDATE no action ON DELETE cascade
+ FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
-PRAGMA foreign_keys=ON;
+DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
diff --git a/packages/db/migrations/sqlite/meta/0042_snapshot.json b/packages/db/migrations/sqlite/meta/0042_snapshot.json
index 7864ccd81..035c92837 100644
--- a/packages/db/migrations/sqlite/meta/0042_snapshot.json
+++ b/packages/db/migrations/sqlite/meta/0042_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "30bbf6f8-3a3c-4cb0-994e-9d8494e4fcd0",
+ "id": "51923913-fdc0-4ae7-9127-e86587e0b51f",
"prevId": "852e049b-72cc-4d7f-8be9-931c042fe019",
"tables": {
"account": {
@@ -515,193 +515,8 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
- "legacy_custom_widget_definition": {
- "name": "legacy_custom_widget_definition",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "icon_url": {
- "name": "icon_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "url": {
- "name": "url",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "auth_type": {
- "name": "auth_type",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'none'"
- },
- "header_name": {
- "name": "header_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "method": {
- "name": "method",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'GET'"
- },
- "request_body": {
- "name": "request_body",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "display_type": {
- "name": "display_type",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'singleValue'"
- },
- "display_config": {
- "name": "display_config",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'{\"json\": {}}'"
- },
- "enabled": {
- "name": "enabled",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": true
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(unixepoch())"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(unixepoch())"
- },
- "creator_id": {
- "name": "creator_id",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "legacy_cw_definition_creator_id_user_id_fk": {
- "name": "legacy_cw_definition_creator_id_user_id_fk",
- "tableFrom": "legacy_custom_widget_definition",
- "tableTo": "user",
- "columnsFrom": ["creator_id"],
- "columnsTo": ["id"],
- "onDelete": "set null",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "legacy_custom_widget_secret": {
- "name": "legacy_custom_widget_secret",
- "columns": {
- "kind": {
- "name": "kind",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "definition_id": {
- "name": "definition_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "legacy_cw_secret_definition_id_fk": {
- "name": "legacy_cw_secret_definition_id_fk",
- "tableFrom": "legacy_custom_widget_secret",
- "tableTo": "legacy_custom_widget_definition",
- "columnsFrom": ["definition_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "legacy_custom_widget_secret_definition_id_kind_pk": {
- "columns": ["definition_id", "kind"],
- "name": "legacy_custom_widget_secret_definition_id_kind_pk"
- }
- },
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "custom_widget_definition": {
- "name": "custom_widget_definition",
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
"columns": {
"id": {
"name": "id",
@@ -793,9 +608,9 @@
},
"indexes": {},
"foreignKeys": {
- "custom_widget_definition_creator_id_user_id_fk": {
- "name": "custom_widget_definition_creator_id_user_id_fk",
- "tableFrom": "custom_widget_definition",
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
@@ -807,8 +622,8 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
- "custom_widget_secret": {
- "name": "custom_widget_secret",
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
"columns": {
"source_id": {
"name": "source_id",
@@ -848,10 +663,10 @@
},
"indexes": {},
"foreignKeys": {
- "cw_secret_definition_id_fk": {
- "name": "cw_secret_definition_id_fk",
- "tableFrom": "custom_widget_secret",
- "tableTo": "custom_widget_definition",
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
@@ -859,9 +674,9 @@
}
},
"compositePrimaryKeys": {
- "custom_widget_secret_definition_id_source_id_kind_pk": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
"columns": ["definition_id", "source_id", "kind"],
- "name": "custom_widget_secret_definition_id_source_id_kind_pk"
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk"
}
},
"uniqueConstraints": {},
@@ -1656,6 +1471,191 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "columns": ["definition_id", "kind"],
+ "name": "custom_widget_secret_definition_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
"media": {
"name": "media",
"columns": {
diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json
index c885bff97..62ec49683 100644
--- a/packages/db/migrations/sqlite/meta/_journal.json
+++ b/packages/db/migrations/sqlite/meta/_journal.json
@@ -300,7 +300,7 @@
"idx": 42,
"version": "6",
"when": 1784305283910,
- "tag": "0042_custom_widget_v2_reset",
+ "tag": "0042_custom_widget_v2_tables",
"breakpoints": true
}
]
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index b4ac86345..be65966e1 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -503,10 +503,10 @@ export const onboarding = mysqlTable("onboarding", {
/**
* Read-only v1 definitions retained during the Custom JSX v2 upgrade.
- * They remain separate so legacy code can never execute in the v2 runtime.
+ * Their original physical table names keep the database readable by a v1 binary.
*/
export const legacyCustomWidgetDefinitions = mysqlTable(
- "legacy_custom_widget_definition",
+ "custom_widget_definition",
{
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
@@ -528,13 +528,13 @@ export const legacyCustomWidgetDefinitions = mysqlTable(
creatorFk: foreignKey({
columns: [table.creatorId],
foreignColumns: [users.id],
- name: "legacy_cw_definition_creator_id_user_id_fk",
+ name: "custom_widget_definition_creator_id_user_id_fk",
}).onDelete("set null"),
}),
);
export const legacyCustomWidgetSecrets = mysqlTable(
- "legacy_custom_widget_secret",
+ "custom_widget_secret",
{
kind: varchar({ length: 64 }).$type().notNull(),
encryptedValue: text("value").$type<`${string}.${string}`>().notNull(),
@@ -546,12 +546,12 @@ export const legacyCustomWidgetSecrets = mysqlTable(
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [legacyCustomWidgetDefinitions.id],
- name: "legacy_cw_secret_definition_id_fk",
+ name: "cw_secret_definition_id_cw_definition_id_fk",
}).onDelete("cascade"),
}),
);
-export const customWidgetDefinitions = mysqlTable("custom_widget_definition", {
+export const customWidgetDefinitions = mysqlTable("custom_widget_v2_definition", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
description: text(),
@@ -567,7 +567,7 @@ export const customWidgetDefinitions = mysqlTable("custom_widget_definition", {
});
export const customWidgetSecrets = mysqlTable(
- "custom_widget_secret",
+ "custom_widget_v2_secret",
{
sourceId: varchar({ length: 64 }).notNull(),
kind: varchar({ length: 64 }).$type().notNull(),
@@ -584,7 +584,7 @@ export const customWidgetSecrets = mysqlTable(
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [customWidgetDefinitions.id],
- name: "cw_secret_definition_id_fk",
+ name: "custom_widget_v2_secret_definition_id_fk",
}).onDelete("cascade"),
}),
);
diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts
index 930c1dfa9..034c8e6a6 100644
--- a/packages/db/schema/postgresql.ts
+++ b/packages/db/schema/postgresql.ts
@@ -502,10 +502,10 @@ export const onboarding = pgTable("onboarding", {
/**
* Read-only v1 definitions retained during the Custom JSX v2 upgrade.
- * They remain separate so legacy code can never execute in the v2 runtime.
+ * Their original physical table names keep the database readable by a v1 binary.
*/
export const legacyCustomWidgetDefinitions = pgTable(
- "legacy_custom_widget_definition",
+ "custom_widget_definition",
{
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
@@ -527,13 +527,13 @@ export const legacyCustomWidgetDefinitions = pgTable(
creatorFk: foreignKey({
columns: [table.creatorId],
foreignColumns: [users.id],
- name: "legacy_cw_definition_creator_id_user_id_fk",
+ name: "custom_widget_definition_creator_id_user_id_fk",
}).onDelete("set null"),
}),
);
export const legacyCustomWidgetSecrets = pgTable(
- "legacy_custom_widget_secret",
+ "custom_widget_secret",
{
kind: varchar({ length: 64 }).$type().notNull(),
encryptedValue: text("value").$type<`${string}.${string}`>().notNull(),
@@ -545,12 +545,12 @@ export const legacyCustomWidgetSecrets = pgTable(
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [legacyCustomWidgetDefinitions.id],
- name: "legacy_cw_secret_definition_id_fk",
+ name: "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
}).onDelete("cascade"),
}),
);
-export const customWidgetDefinitions = pgTable("custom_widget_definition", {
+export const customWidgetDefinitions = pgTable("custom_widget_v2_definition", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
description: text(),
@@ -566,7 +566,7 @@ export const customWidgetDefinitions = pgTable("custom_widget_definition", {
});
export const customWidgetSecrets = pgTable(
- "custom_widget_secret",
+ "custom_widget_v2_secret",
{
sourceId: varchar({ length: 64 }).notNull(),
kind: varchar({ length: 64 }).$type().notNull(),
@@ -583,7 +583,7 @@ export const customWidgetSecrets = pgTable(
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [customWidgetDefinitions.id],
- name: "cw_secret_definition_id_fk",
+ name: "custom_widget_v2_secret_definition_id_fk",
}).onDelete("cascade"),
}),
);
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index 9395a1d6f..4bf8cde7b 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -487,10 +487,10 @@ export const onboarding = sqliteTable("onboarding", {
/**
* Read-only v1 definitions retained during the Custom JSX v2 upgrade.
- * They remain separate so legacy code can never execute in the v2 runtime.
+ * Their original physical table names keep the database readable by a v1 binary.
*/
export const legacyCustomWidgetDefinitions = sqliteTable(
- "legacy_custom_widget_definition",
+ "custom_widget_definition",
{
id: text().notNull().primaryKey(),
name: text().notNull(),
@@ -516,13 +516,13 @@ export const legacyCustomWidgetDefinitions = sqliteTable(
creatorFk: foreignKey({
columns: [table.creatorId],
foreignColumns: [users.id],
- name: "legacy_cw_definition_creator_id_user_id_fk",
+ name: "custom_widget_definition_creator_id_user_id_fk",
}).onDelete("set null"),
}),
);
export const legacyCustomWidgetSecrets = sqliteTable(
- "legacy_custom_widget_secret",
+ "custom_widget_secret",
{
kind: text().$type().notNull(),
encryptedValue: text("value").$type<`${string}.${string}`>().notNull(),
@@ -534,12 +534,12 @@ export const legacyCustomWidgetSecrets = sqliteTable(
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [legacyCustomWidgetDefinitions.id],
- name: "legacy_cw_secret_definition_id_fk",
+ name: "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
}).onDelete("cascade"),
}),
);
-export const customWidgetDefinitions = sqliteTable("custom_widget_definition", {
+export const customWidgetDefinitions = sqliteTable("custom_widget_v2_definition", {
id: text().notNull().primaryKey(),
name: text().notNull(),
description: text(),
@@ -559,7 +559,7 @@ export const customWidgetDefinitions = sqliteTable("custom_widget_definition", {
});
export const customWidgetSecrets = sqliteTable(
- "custom_widget_secret",
+ "custom_widget_v2_secret",
{
sourceId: text().notNull(),
kind: text().$type().notNull(),
@@ -576,7 +576,7 @@ export const customWidgetSecrets = sqliteTable(
definitionFk: foreignKey({
columns: [table.definitionId],
foreignColumns: [customWidgetDefinitions.id],
- name: "cw_secret_definition_id_fk",
+ name: "custom_widget_v2_secret_definition_id_fk",
}).onDelete("cascade"),
}),
);
diff --git a/packages/db/test/mysql-migration.spec.ts b/packages/db/test/mysql-migration.spec.ts
index 29b15bcd0..93115a3e4 100644
--- a/packages/db/test/mysql-migration.spec.ts
+++ b/packages/db/test/mysql-migration.spec.ts
@@ -4,6 +4,7 @@ import { MySqlContainer } from "@testcontainers/mysql";
import { drizzle } from "drizzle-orm/mysql2";
import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
+import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
@@ -13,6 +14,16 @@ import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
+const applyMigration = async (connection: mysql.Connection, fileName: string) => {
+ const migration = readFileSync(path.join(__dirname, "..", "migrations", "mysql", fileName), "utf8");
+ for (const statement of migration
+ .split("--> statement-breakpoint")
+ .map((value) => value.trim())
+ .filter(Boolean)) {
+ await connection.promise().query(statement);
+ }
+};
+
describe("Mysql Migration", () => {
test("should add all tables and keys specified in migration files", async () => {
const mysqlContainer = await new MySqlContainer("mysql:latest").start();
@@ -40,12 +51,16 @@ describe("Mysql Migration", () => {
// Check if users table exists
await database.query.users.findMany();
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
+ const [customWidgetTables] = await connection
+ .promise()
+ .query("SHOW TABLES LIKE 'custom_widget%'");
+ expect(customWidgetTables).toHaveLength(4);
connection.end();
await mysqlContainer.stop();
}, 90_000);
- test("preserves populated v1 custom widgets and encrypted secrets", async () => {
+ test("Custom Widget v2 migration preserves v1 data and board references", async () => {
const mysqlContainer = await new MySqlContainer("mysql:latest").start();
const connection = mysql.createConnection({
host: mysqlContainer.getHost(),
@@ -57,8 +72,18 @@ describe("Mysql Migration", () => {
});
const sql = connection.promise();
try {
+ const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
await sql.query(`
CREATE TABLE \`user\` (\`id\` varchar(64) NOT NULL PRIMARY KEY);
+ CREATE TABLE \`groupPermission\` (
+ \`group_id\` varchar(64) NOT NULL,
+ \`permission\` text NOT NULL
+ );
+ CREATE TABLE \`item\` (
+ \`id\` varchar(64) NOT NULL PRIMARY KEY,
+ \`kind\` varchar(64) NOT NULL,
+ \`options\` text NOT NULL
+ );
CREATE TABLE \`custom_widget_definition\` (
\`id\` varchar(64) NOT NULL PRIMARY KEY,
\`name\` varchar(256) NOT NULL,
@@ -98,31 +123,77 @@ describe("Mysql Migration", () => {
);
INSERT INTO \`custom_widget_secret\` (\`kind\`, \`value\`, \`updated_at\`, \`definition_id\`)
VALUES ('apiKey', 'encrypted.value', '2023-11-14 00:00:01', 'legacy-weather');
+ INSERT INTO \`groupPermission\` (\`group_id\`, \`permission\`) VALUES
+ ('editors', 'custom-widget-manage'),
+ ('editors', 'custom-widget-secret-write'),
+ ('editors', 'board-create');
+ `);
+ await sql.execute("INSERT INTO `item` (`id`, `kind`, `options`) VALUES ('weather-item', 'customApi', ?)", [
+ legacyPlacementOptions,
+ ]);
+
+ await applyMigration(connection, "0044_custom_widget_v2_tables.sql");
+ await sql.query(`
+ INSERT INTO \`custom_widget_v2_definition\` (
+ \`id\`, \`name\`, \`sources\`, \`requests\`, \`options\`, \`template\`, \`enabled\`,
+ \`created_at\`, \`updated_at\`, \`creator_id\`
+ ) VALUES (
+ 'legacy-weather', 'Weather v2', '[]', '[]', '[]', 'Weather ', true,
+ '2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
+ );
+ INSERT INTO \`custom_widget_v2_secret\` (
+ \`source_id\`, \`kind\`, \`encrypted_value\`, \`updated_at\`, \`definition_id\`
+ ) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
`);
- const migration = readFileSync(
- path.join(__dirname, "..", "migrations", "mysql", "0044_custom_widget_v2_reset.sql"),
- "utf8",
- );
- for (const statement of migration
- .split("--> statement-breakpoint")
- .map((value) => value.trim())
- .filter(Boolean)) {
- await sql.query(statement);
- }
-
const [definitions] = await sql.query(
- "SELECT id, name, enabled, creator_id FROM legacy_custom_widget_definition",
+ "SELECT id, name, url, enabled, creator_id FROM custom_widget_definition",
);
const [secrets] = await sql.query(
- "SELECT definition_id, kind, value FROM legacy_custom_widget_secret",
+ "SELECT definition_id, kind, value FROM custom_widget_secret",
);
expect(definitions).toEqual([
- expect.objectContaining({ id: "legacy-weather", name: "Weather", enabled: 1, creator_id: "owner" }),
+ expect.objectContaining({
+ id: "legacy-weather",
+ name: "Weather",
+ url: "https://example.test/weather",
+ enabled: 1,
+ creator_id: "owner",
+ }),
]);
expect(secrets).toEqual([
expect.objectContaining({ definition_id: "legacy-weather", kind: "apiKey", value: "encrypted.value" }),
]);
+ const [v2Definitions] = await sql.query(
+ "SELECT id, name, template FROM custom_widget_v2_definition",
+ );
+ const [v2Secrets] = await sql.query(
+ "SELECT definition_id, source_id, kind, encrypted_value FROM custom_widget_v2_secret",
+ );
+ expect(v2Definitions).toEqual([
+ expect.objectContaining({ id: "legacy-weather", name: "Weather v2", template: "Weather " }),
+ ]);
+ expect(v2Secrets).toEqual([
+ expect.objectContaining({
+ definition_id: "legacy-weather",
+ source_id: "weather",
+ kind: "apiKey",
+ encrypted_value: "encrypted.v2-value",
+ }),
+ ]);
+ const [items] = await sql.query("SELECT options FROM item WHERE id = 'weather-item'");
+ expect(items).toEqual([{ options: legacyPlacementOptions }]);
+ const [permissions] = await sql.query(
+ "SELECT permission FROM groupPermission ORDER BY permission",
+ );
+ expect(permissions).toEqual([{ permission: "board-create" }]);
+ const [v1Columns] = await sql.query("SHOW COLUMNS FROM custom_widget_definition");
+ expect(v1Columns.map((column) => column.Field)).toEqual(
+ expect.arrayContaining(["id", "url", "auth_type", "display_type", "display_config"]),
+ );
+ expect(v1Columns.map((column) => column.Field)).not.toContain("sources");
+ const [legacyTables] = await sql.query("SHOW TABLES LIKE 'legacy_custom_widget%'");
+ expect(legacyTables).toEqual([]);
} finally {
await sql.end();
await mysqlContainer.stop();
diff --git a/packages/db/test/postgresql-migration.spec.ts b/packages/db/test/postgresql-migration.spec.ts
index a2c8b2b65..de29775c0 100644
--- a/packages/db/test/postgresql-migration.spec.ts
+++ b/packages/db/test/postgresql-migration.spec.ts
@@ -4,6 +4,7 @@ import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
+import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
@@ -13,6 +14,16 @@ import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
+const applyMigration = async (pool: Pool, fileName: string) => {
+ const migration = readFileSync(path.join(__dirname, "..", "migrations", "postgresql", fileName), "utf8");
+ for (const statement of migration
+ .split("--> statement-breakpoint")
+ .map((value) => value.trim())
+ .filter(Boolean)) {
+ await pool.query(statement);
+ }
+};
+
describe("PostgreSql Migration", () => {
test("should add all tables and keys specified in migration files", async () => {
const container = new PostgreSqlContainer("postgres:latest");
@@ -45,6 +56,13 @@ describe("PostgreSql Migration", () => {
// Check if users table exists
await database.query.users.findMany();
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
+ expect(
+ (
+ await pool.query(
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'custom_widget%'",
+ )
+ ).rows,
+ ).toHaveLength(4);
// Close the pool to release resources
await pool.end();
@@ -52,7 +70,7 @@ describe("PostgreSql Migration", () => {
await postgreSqlContainer.stop();
}, 40_000);
- test("preserves populated v1 custom widgets and encrypted secrets", async () => {
+ test("Custom Widget v2 migration preserves v1 data and board references", async () => {
const postgreSqlContainer = await new PostgreSqlContainer("postgres:latest").start();
const pool = new Pool({
user: postgreSqlContainer.getUsername(),
@@ -62,8 +80,18 @@ describe("PostgreSql Migration", () => {
host: postgreSqlContainer.getHost(),
});
try {
+ const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
await pool.query(`
CREATE TABLE "user" ("id" varchar(64) PRIMARY KEY NOT NULL);
+ CREATE TABLE "groupPermission" (
+ "group_id" varchar(64) NOT NULL,
+ "permission" text NOT NULL
+ );
+ CREATE TABLE "item" (
+ "id" varchar(64) PRIMARY KEY NOT NULL,
+ "kind" varchar(64) NOT NULL,
+ "options" text NOT NULL
+ );
CREATE TABLE "custom_widget_definition" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
@@ -103,27 +131,76 @@ describe("PostgreSql Migration", () => {
);
INSERT INTO "custom_widget_secret" ("kind", "value", "updated_at", "definition_id")
VALUES ('apiKey', 'encrypted.value', '2023-11-14 00:00:01', 'legacy-weather');
+ INSERT INTO "groupPermission" ("group_id", "permission") VALUES
+ ('editors', 'custom-widget-manage'),
+ ('editors', 'custom-widget-secret-write'),
+ ('editors', 'board-create');
+ `);
+ await pool.query(`INSERT INTO "item" ("id", "kind", "options") VALUES ('weather-item', 'customApi', $1)`, [
+ legacyPlacementOptions,
+ ]);
+
+ await applyMigration(pool, "0010_custom_widget_v2_tables.sql");
+ await pool.query(`
+ INSERT INTO "custom_widget_v2_definition" (
+ "id", "name", "sources", "requests", "options", "template", "enabled",
+ "created_at", "updated_at", "creator_id"
+ ) VALUES (
+ 'legacy-weather', 'Weather v2', '[]', '[]', '[]', 'Weather ', true,
+ '2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
+ );
+ INSERT INTO "custom_widget_v2_secret" (
+ "source_id", "kind", "encrypted_value", "updated_at", "definition_id"
+ ) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
`);
- const migration = readFileSync(
- path.join(__dirname, "..", "migrations", "postgresql", "0010_custom_widget_v2_reset.sql"),
- "utf8",
- );
- for (const statement of migration
- .split("--> statement-breakpoint")
- .map((value) => value.trim())
- .filter(Boolean)) {
- await pool.query(statement);
- }
-
expect(
- (await pool.query("SELECT id, name, enabled, creator_id FROM legacy_custom_widget_definition")).rows,
+ (await pool.query("SELECT id, name, url, enabled, creator_id FROM custom_widget_definition")).rows,
).toEqual([
- expect.objectContaining({ id: "legacy-weather", name: "Weather", enabled: true, creator_id: "owner" }),
+ expect.objectContaining({
+ id: "legacy-weather",
+ name: "Weather",
+ url: "https://example.test/weather",
+ enabled: true,
+ creator_id: "owner",
+ }),
]);
- expect((await pool.query("SELECT definition_id, kind, value FROM legacy_custom_widget_secret")).rows).toEqual([
+ expect((await pool.query("SELECT definition_id, kind, value FROM custom_widget_secret")).rows).toEqual([
expect.objectContaining({ definition_id: "legacy-weather", kind: "apiKey", value: "encrypted.value" }),
]);
+ expect((await pool.query("SELECT id, name, template FROM custom_widget_v2_definition")).rows).toEqual([
+ expect.objectContaining({ id: "legacy-weather", name: "Weather v2", template: "Weather " }),
+ ]);
+ expect(
+ (await pool.query("SELECT definition_id, source_id, kind, encrypted_value FROM custom_widget_v2_secret")).rows,
+ ).toEqual([
+ expect.objectContaining({
+ definition_id: "legacy-weather",
+ source_id: "weather",
+ kind: "apiKey",
+ encrypted_value: "encrypted.v2-value",
+ }),
+ ]);
+ expect((await pool.query("SELECT options FROM item WHERE id = 'weather-item'")).rows).toEqual([
+ { options: legacyPlacementOptions },
+ ]);
+ expect((await pool.query('SELECT "permission" FROM "groupPermission" ORDER BY "permission"')).rows).toEqual([
+ { permission: "board-create" },
+ ]);
+ const v1Columns = (
+ await pool.query(
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'custom_widget_definition'",
+ )
+ ).rows.map((column: { column_name: string }) => column.column_name);
+ expect(v1Columns).toEqual(expect.arrayContaining(["id", "url", "auth_type", "display_type", "display_config"]));
+ expect(v1Columns).not.toContain("sources");
+ expect(
+ (
+ await pool.query(
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'legacy_custom_widget%'",
+ )
+ ).rows,
+ ).toEqual([]);
} finally {
await pool.end();
await postgreSqlContainer.stop();
diff --git a/packages/db/test/sqlite-migration.spec.ts b/packages/db/test/sqlite-migration.spec.ts
index 82aa0e1cb..ff21bbd9b 100644
--- a/packages/db/test/sqlite-migration.spec.ts
+++ b/packages/db/test/sqlite-migration.spec.ts
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import BetterSqlite3 from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
+import SuperJSON from "superjson";
import { expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
@@ -12,6 +13,16 @@ import { seedDataAsync } from "../migrations/seed";
import * as sqliteSchema from "../schema/sqlite";
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
+const applyMigration = (connection: BetterSqlite3.Database, fileName: string) => {
+ const migration = readFileSync(path.join(__dirname, "..", "migrations", "sqlite", fileName), "utf8");
+ for (const statement of migration
+ .split("--> statement-breakpoint")
+ .map((value) => value.trim())
+ .filter(Boolean)) {
+ connection.exec(statement);
+ }
+};
+
test("SQLite migrations seed the five disabled bundled custom widgets", async () => {
const connection = new BetterSqlite3(":memory:");
const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
@@ -20,14 +31,34 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
await seedDataAsync(database as unknown as Database);
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
+ expect(
+ connection
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'custom_widget%' ORDER BY name")
+ .all(),
+ ).toEqual([
+ { name: "custom_widget_definition" },
+ { name: "custom_widget_secret" },
+ { name: "custom_widget_v2_definition" },
+ { name: "custom_widget_v2_secret" },
+ ]);
connection.close();
});
-test("Custom JSX v2 migration preserves populated v1 definitions and secrets", () => {
+test("Custom Widget v2 migration preserves v1 data and board references", () => {
const connection = new BetterSqlite3(":memory:");
+ const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
connection.exec(`
CREATE TABLE user (id text PRIMARY KEY NOT NULL);
+ CREATE TABLE groupPermission (
+ group_id text NOT NULL,
+ permission text NOT NULL
+ );
+ CREATE TABLE item (
+ id text PRIMARY KEY NOT NULL,
+ kind text NOT NULL,
+ options text NOT NULL
+ );
CREATE TABLE custom_widget_definition (
id text PRIMARY KEY NOT NULL,
name text NOT NULL,
@@ -59,32 +90,75 @@ test("Custom JSX v2 migration preserves populated v1 definitions and secrets", (
'{"type":"singleValue","jsonPath":"$.temperature"}', true, 1700000000, 1700000001, 'owner');
INSERT INTO custom_widget_secret (kind, value, updated_at, definition_id)
VALUES ('apiKey', 'encrypted.value', 1700000001, 'legacy-weather');
+ INSERT INTO groupPermission (group_id, permission) VALUES
+ ('editors', 'custom-widget-manage'),
+ ('editors', 'custom-widget-secret-write'),
+ ('editors', 'board-create');
+ `);
+ connection
+ .prepare("INSERT INTO item (id, kind, options) VALUES ('weather-item', 'customApi', ?)")
+ .run(legacyPlacementOptions);
+
+ applyMigration(connection, "0042_custom_widget_v2_tables.sql");
+ connection.exec(`
+ INSERT INTO custom_widget_v2_definition (
+ id, name, sources, requests, options, template, enabled, created_at, updated_at, creator_id
+ ) VALUES (
+ 'legacy-weather', 'Weather v2', '[]', '[]', '[]', 'Weather ', true,
+ 1700000002, 1700000003, 'owner'
+ );
+ INSERT INTO custom_widget_v2_secret (source_id, kind, encrypted_value, updated_at, definition_id)
+ VALUES ('weather', 'apiKey', 'encrypted.v2-value', 1700000003, 'legacy-weather');
`);
- const migration = readFileSync(
- path.join(__dirname, "..", "migrations", "sqlite", "0042_custom_widget_v2_reset.sql"),
- "utf8",
- );
- for (const statement of migration
- .split("--> statement-breakpoint")
- .map((value) => value.trim())
- .filter(Boolean)) {
- connection.exec(statement);
- }
-
- expect(connection.prepare("SELECT * FROM legacy_custom_widget_definition").get()).toMatchObject({
+ expect(connection.prepare("SELECT * FROM custom_widget_definition").get()).toMatchObject({
id: "legacy-weather",
name: "Weather",
+ url: "https://example.test/weather",
enabled: 1,
creator_id: "owner",
});
- expect(connection.prepare("SELECT * FROM legacy_custom_widget_secret").get()).toMatchObject({
+ expect(connection.prepare("SELECT * FROM custom_widget_secret").get()).toMatchObject({
definition_id: "legacy-weather",
kind: "apiKey",
value: "encrypted.value",
});
- expect(connection.prepare("SELECT count(*) AS count FROM custom_widget_definition").get()).toEqual({ count: 0 });
- expect(connection.prepare("SELECT count(*) AS count FROM custom_widget_secret").get()).toEqual({ count: 0 });
+ expect(connection.prepare("SELECT * FROM custom_widget_v2_definition").get()).toMatchObject({
+ id: "legacy-weather",
+ name: "Weather v2",
+ template: "Weather ",
+ });
+ expect(connection.prepare("SELECT * FROM custom_widget_v2_secret").get()).toMatchObject({
+ definition_id: "legacy-weather",
+ source_id: "weather",
+ encrypted_value: "encrypted.v2-value",
+ });
+ expect(connection.prepare("SELECT options FROM item WHERE id = 'weather-item'").get()).toEqual({
+ options: legacyPlacementOptions,
+ });
+ expect(connection.prepare("SELECT permission FROM groupPermission ORDER BY permission").all()).toEqual([
+ { permission: "board-create" },
+ ]);
+ expect(
+ connection
+ .prepare(
+ `SELECT id, name, url, auth_type, header_name, method, request_body, display_type, display_config,
+ enabled, created_at, updated_at, creator_id
+ FROM custom_widget_definition`,
+ )
+ .get(),
+ ).toMatchObject({
+ id: "legacy-weather",
+ auth_type: "bearer",
+ display_type: "singleValue",
+ });
+ expect(
+ connection
+ .prepare(
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'legacy_custom_widget%' ORDER BY name",
+ )
+ .all(),
+ ).toEqual([]);
connection.close();
});
diff --git a/packages/definitions/src/docs/index.ts b/packages/definitions/src/docs/index.ts
index af6731239..50684f3ad 100644
--- a/packages/definitions/src/docs/index.ts
+++ b/packages/definitions/src/docs/index.ts
@@ -1,6 +1,15 @@
import type { HomarrDocumentationPath } from "./homarr-docs-sitemap";
-const documentationBaseUrl = "https://homarr.dev";
+const defaultDocumentationBaseUrl = "https://homarr.dev";
+
+const getDocumentationBaseUrl = () => {
+ const browserValue =
+ typeof document === "undefined"
+ ? undefined
+ : document.querySelector('meta[name="homarr-website-url"]')?.content;
+ const serverValue = typeof process === "undefined" ? undefined : process.env.HOMARR_WEBSITE_URL;
+ return (browserValue || serverValue || defaultDocumentationBaseUrl).replace(/\/+$/u, "");
+};
// Please use the method so the path can be checked!
export const createDocumentationLink = (
@@ -8,7 +17,7 @@ export const createDocumentationLink = (
hashTag?: `#${string}`,
queryParams?: Record,
) => {
- const url = `${documentationBaseUrl}${path}`;
+ const url = `${getDocumentationBaseUrl()}${path}`;
const params = queryParams ? `?${new URLSearchParams(queryParams)}` : "";
return `${url}${params}${hashTag ?? ""}`;
};
diff --git a/packages/definitions/src/permissions.ts b/packages/definitions/src/permissions.ts
index ef44b8f81..0ceb648be 100644
--- a/packages/definitions/src/permissions.ts
+++ b/packages/definitions/src/permissions.ts
@@ -42,7 +42,6 @@ export const groupPermissions = {
integration: ["create", "use-all", "interact-all", "full-all"],
"search-engine": ["create", "modify-all", "full-all"],
media: ["upload", "view-all", "full-all"],
- "custom-widget": ["manage", "secret-write"],
other: ["view-logs"],
admin: true,
} as const;
@@ -68,8 +67,6 @@ const groupPermissionParents = {
"integration-full-all",
"search-engine-full-all",
"media-full-all",
- "custom-widget-manage",
- "custom-widget-secret-write",
"other-view-logs",
],
} satisfies Partial>;
diff --git a/packages/definitions/src/test/docs.spec.ts b/packages/definitions/src/test/docs.spec.ts
index d7690f22e..86d19cc74 100644
--- a/packages/definitions/src/test/docs.spec.ts
+++ b/packages/definitions/src/test/docs.spec.ts
@@ -30,6 +30,21 @@ describe("createDocumentationLink should generate correct URLs", () => {
});
describe("createDocumentationLink parameter validation", () => {
+ test("uses the server-rendered website URL at runtime", () => {
+ const meta = document.createElement("meta");
+ meta.name = "homarr-website-url";
+ meta.content = "https://docs.example.com/base/";
+ document.head.append(meta);
+
+ try {
+ expect(createDocumentationLink("/docs/getting-started")).toBe(
+ "https://docs.example.com/base/docs/getting-started",
+ );
+ } finally {
+ meta.remove();
+ }
+ });
+
test("should work with only path parameter", () => {
const result = createDocumentationLink("/docs/getting-started");
expect(result).toBe("https://homarr.dev/docs/getting-started");
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index b81ad9278..5d73bc724 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -735,7 +735,7 @@
"invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.",
"migrationRequired": "Migration required",
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
- "migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then import the v2 JSON it produces. Existing board placements keep working after migration.",
+ "migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then copy its single JSON response and choose Paste migrated widget. Existing board placements keep working after migration.",
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
},
@@ -765,7 +765,8 @@
"publishWorkshop": "Publish to Workshop",
"migrate": "Migrate Custom Widget",
"copyMigrationPrompt": "Copy LLM migration prompt",
- "importMigration": "Import migrated v2 JSON"
+ "pasteMigration": "Paste migrated widget",
+ "importMigration": "Import migrated JSON file"
},
"field": {
"name": "Name",
@@ -972,6 +973,7 @@
},
"ai": {
"title": "Create or fix with any AI",
+ "description": "Copy one prompt, then paste the AI's single JSON block. Homarr loads its JSX into the editor automatically.",
"request": "What should this widget do?",
"documentationUrl": "API documentation URL (optional)",
"paste": "Paste AI response",
@@ -1072,7 +1074,7 @@
"imported": "Widget imported successfully.",
"migrated": "Legacy widget migrated successfully. Existing board placements now use the v2 definition.",
"migrationError": "Failed to migrate the legacy widget. Ensure the file uses the homarr-custom-widget-v2 schema.",
- "migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, save the returned v2 JSON, then import it here.",
+ "migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
"migrationPromptCopyError": "Failed to copy the migration prompt.",
"aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
@@ -1134,6 +1136,7 @@
"installError": "Could not install widget",
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
"title": "Workshop",
+ "openCommunity": "Open public Workshop",
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
@@ -1146,7 +1149,9 @@
"sort": "Sort",
"sortTop": "Top rated",
"sortNewest": "Newest",
- "unavailable": "Workshop is unavailable. Your installed widgets are unaffected.",
+ "unavailableTitle": "Homarr Workshop seems to be unavailable",
+ "unavailable": "The community service may be temporarily down. Try again in a moment. Your installed widgets are unaffected.",
+ "retry": "Try again",
"empty": "No widgets match this search.",
"emptyCss": "No CSS themes match this search.",
"noDescription": "No description",
@@ -1165,10 +1170,10 @@
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
"reportTitle": "Report submission",
"reportReason": "Reason",
- "reportExplanation": "What should the administrators know?",
+ "reportExplanation": "What should the Workshop moderators know?",
"reportSend": "Send report",
"reportSent": "Report sent",
- "reportSentDescription": "Workshop administrators can now review this submission.",
+ "reportSentDescription": "Workshop moderators can now review this submission.",
"voteError": "The vote could not be saved.",
"publish": {
"title": "Publish to Workshop",
@@ -4182,6 +4187,7 @@
"definitionNotFound": "Custom widget unavailable",
"migrationRequired": "This legacy custom widget is preserved, but it must be migrated to Custom JSX v2 from Manage custom widgets before it can run.",
"manageMigration": "Open Manage custom widgets",
+ "contactAdmin": "Contact an administrator.",
"configurationNeedsRepair": "This widget's options changed. Edit the widget to repair its configuration.",
"removeFromBoard": "Remove from board",
"editModePending": "Save the board to load this widget.",
@@ -4192,8 +4198,9 @@
"sources": "{count, plural, one {# source} other {# sources}}",
"requests": "{count, plural, one {# request} other {# requests}}",
"migrationRequired": "Migration required",
- "migrationDescription": "This placed v1 widget cannot run yet. Copy its redacted prompt to an LLM and import the returned v2 JSON.",
- "manageMigration": "Open Manage custom widgets"
+ "migrationDescription": "This placed v1 widget cannot run yet. Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
+ "manageMigration": "Open Manage custom widgets",
+ "contactAdmin": "Contact an administrator."
},
"configuration": {
"needsAttention": "Configuration needs attention: {message}",
diff --git a/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx b/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx
index 49d857a08..95103f25f 100644
--- a/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx
+++ b/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx
@@ -10,6 +10,7 @@ import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-inp
const mocks = vi.hoisted(() => ({
available: [] as Array>,
+ permissions: ["admin"] as string[],
setFieldValue: vi.fn(),
}));
@@ -62,6 +63,10 @@ vi.mock("@homarr/api/client", () => ({
},
}));
+vi.mock("@homarr/auth/client", () => ({
+ useSession: () => ({ data: { user: { permissions: mocks.permissions } } }),
+}));
+
vi.mock("@homarr/boards/context", () => ({
useOptionalBoard: () => ({ id: "board-1" }),
}));
@@ -70,8 +75,9 @@ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
- migrationDescription: "Copy its redacted prompt to an LLM and import the returned v2 JSON.",
+ migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
manageMigration: "Open Manage custom widgets",
+ contactAdmin: "Contact an administrator.",
})[key] ?? key,
}));
@@ -90,6 +96,7 @@ let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.available = [];
+ mocks.permissions = ["admin"];
mocks.setFieldValue.mockReset();
host = document.createElement("div");
document.body.append(host);
@@ -137,4 +144,26 @@ describe("custom widget picker migration status", () => {
expect(host.textContent).toContain("Copy its redacted prompt to an LLM");
expect(host.querySelector('a[href="/manage/custom-widgets"]')?.textContent).toBe("Open Manage custom widgets");
});
+
+ it("tells non-admins to contact an administrator without linking management", async () => {
+ mocks.permissions = ["board-modify-all"];
+ mocks.available = [
+ {
+ id: "legacy-widget",
+ name: "Legacy status",
+ description: "Old API widget",
+ iconUrl: null,
+ sources: [],
+ requestCapabilities: [],
+ defaultOptions: {},
+ updatedAt: new Date(0),
+ migrationRequired: true,
+ },
+ ];
+
+ await renderInput();
+
+ expect(host.textContent).toContain("Contact an administrator.");
+ expect(host.querySelector('a[href="/manage/custom-widgets"]')).toBeNull();
+ });
});
diff --git a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx
index c805b9fe0..24276feea 100644
--- a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx
+++ b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx
@@ -17,6 +17,7 @@ import {
import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
+import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -32,6 +33,8 @@ export const WidgetCustomWidgetSelectInput = ({
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
+ const { data: session } = useSession();
+ const isAdmin = session?.user.permissions.includes("admin") ?? false;
const form = useFormContext();
const board = useOptionalBoard();
const currentValue = form.values.options[property] as string;
@@ -153,9 +156,15 @@ export const WidgetCustomWidgetSelectInput = ({
title={labels("migrationRequired")}
>
{labels("migrationDescription")}
-
- {labels("manageMigration")}
-
+ {isAdmin ? (
+
+ {labels("manageMigration")}
+
+ ) : (
+
+ {labels("contactAdmin")}
+
+ )}
)}
diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx
index 00655b41a..797daa158 100644
--- a/packages/widgets/src/custom-api/component.tsx
+++ b/packages/widgets/src/custom-api/component.tsx
@@ -5,6 +5,7 @@ import { Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
+import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -21,6 +22,8 @@ export default function CustomApiWidget({
}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
+ const { data: session } = useSession();
+ const isAdmin = session?.user.permissions.includes("admin") ?? false;
const definitionId = options.definitionId;
const safeInterval = Number.isFinite(options.refreshInterval) ? options.refreshInterval : 30;
const intervalMs = Math.max(1_000, safeInterval * 1_000);
@@ -64,8 +67,9 @@ export default function CustomApiWidget({
}
danger={!isUnavailable && !migrationRequired}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
- actionLabel={migrationRequired ? t("manageMigration") : undefined}
- actionHref={migrationRequired ? "/manage/custom-widgets" : undefined}
+ actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
+ actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
+ supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
onRemove={removeItem}
/>
);
@@ -90,6 +94,7 @@ function Unavailable({
removeLabel,
actionLabel,
actionHref,
+ supplementaryMessage,
onRemove,
}: {
message: string;
@@ -97,6 +102,7 @@ function Unavailable({
removeLabel?: string;
actionLabel?: string;
actionHref?: string;
+ supplementaryMessage?: string;
onRemove?: () => void;
}) {
return (
@@ -106,6 +112,11 @@ function Unavailable({
{message}
+ {supplementaryMessage && (
+
+ {supplementaryMessage}
+
+ )}
{actionLabel && actionHref && (
{actionLabel}
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 3edb6ffe5..7c708317f 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -92,6 +92,7 @@ export const WidgetEditModal = createModal>(({ actions, i
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
+ const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
@@ -120,7 +121,7 @@ export const WidgetEditModal = createModal>(({ actions, i
const widgetFormContent = (
- {innerProps.integrationSupport && (
+ {canConfigureWidget && innerProps.integrationSupport && (
>(({ actions, i
{...form.getInputProps("integrationIds")}
/>
)}
- {Object.entries(options).map(([key, value]) => {
- const Input = getInputForType(value.type);
+ {canConfigureWidget &&
+ Object.entries(options).map(([key, value]) => {
+ const Input = getInputForType(value.type);
- if (
- !Input ||
- value.shouldHide?.(
- form.values.options as never,
- innerProps.integrationData
- .filter(({ id }) => form.values.integrationIds.includes(id))
- .map(({ kind }) => kind),
- )
- ) {
- return null;
- }
+ if (
+ !Input ||
+ value.shouldHide?.(
+ form.values.options as never,
+ innerProps.integrationData
+ .filter(({ id }) => form.values.integrationIds.includes(id))
+ .map(({ kind }) => kind),
+ )
+ ) {
+ return null;
+ }
- return (
-
- );
- })}
+ return (
+
+ );
+ })}
{showAppTab ? (
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
);
-function oauthText(...values: unknown[]) {
- return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
-}
-
-function githubProfile(meta: Record | undefined, record: { id: string }) {
- const rawUser =
- meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record) : undefined;
- const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login);
- const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url);
- const githubUsername = oauthText(meta?.username, rawUser?.login);
- return {
- displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100),
- avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined,
- githubUsername,
- githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : undefined,
- };
-}
-
const timestamp = (value: string) => (Number.isNaN(Date.parse(value)) ? 0 : Date.parse(value));
function sortListings(items: WorkshopSubmissionSummary[], sort: WorkshopListOptions["sort"]) {
@@ -274,6 +257,13 @@ function workshopError(error: unknown, fallback: string) {
/invalid|missing|not found|failed/iu.test(rawMessage)
)
return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error });
+ if (
+ (error instanceof ClientResponseError && error.status === 0) ||
+ /networkerror|failed to fetch|fetch failed|load failed|connection (?:refused|reset)|econn(?:refused|reset)|offline/iu.test(
+ rawMessage,
+ )
+ )
+ return new Error("Homarr Workshop seems to be unavailable right now. Try again in a moment.", { cause: error });
if (error instanceof ClientResponseError) {
const sdkMessage = error.data?.message || error.message;
const originalMessage =
@@ -349,9 +339,7 @@ export class WorkshopBackend {
provider: "github",
createData: { displayName: "GitHub user" },
});
- const profile = githubProfile(auth.meta, auth.record);
- const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
- this.pocketBase.authStore.save(auth.token, updated);
+ this.pocketBase.authStore.save(auth.token, auth.record);
return this.currentUser;
} catch (error) {
throw workshopError(error, "GitHub sign-in failed");
@@ -457,6 +445,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);
@@ -578,7 +567,8 @@ export class WorkshopBackend {
public async toggleOutdated(id: string, outdated: boolean) {
try {
- await this.pocketBase.collection("submissions").update(id, { outdated });
+ const current = await this.get(id);
+ await this.pocketBase.collection("submissions").update(id, { outdated, expectedRevision: current.revision });
return this.get(id);
} catch (error) {
throw workshopError(error, "Failed to update submission status");
diff --git a/packages/workshop/src/client.spec.ts b/packages/workshop/src/client.spec.ts
index c9db7dda1..5942cabc8 100644
--- a/packages/workshop/src/client.spec.ts
+++ b/packages/workshop/src/client.spec.ts
@@ -68,13 +68,17 @@ describe("WorkshopBackend", () => {
test("lets PocketBase own the complete GitHub popup flow", async () => {
mocks.authWithOAuth2.mockResolvedValue({
token: "token",
- record: { id: "user-id" },
+ record: {
+ id: "user-id",
+ displayName: "octocat",
+ githubUsername: "octocat",
+ githubProfileUrl: "https://github.com/octocat",
+ },
meta: { username: "octocat" },
});
- mocks.update.mockResolvedValue({ id: "user-id", displayName: "octocat" });
const client = new WorkshopBackend("https://workshop.example.com");
- await client.signInWithGitHub();
+ const user = await client.signInWithGitHub();
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
provider: "github",
@@ -82,6 +86,8 @@ describe("WorkshopBackend", () => {
});
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
+ expect(mocks.update).not.toHaveBeenCalled();
+ expect(user).toMatchObject({ displayName: "octocat", githubUsername: "octocat" });
});
test("exposes the typed submission collection service", () => {
@@ -90,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" });
@@ -107,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();
});
@@ -150,6 +156,31 @@ describe("WorkshopBackend", () => {
expect(payload).not.toHaveProperty("widgetSchema");
});
+ test("uses the current revision when toggling submission status", async () => {
+ const client = new WorkshopBackend("https://workshop.example.com");
+ const current = {
+ ...listingRecord(),
+ type: "customCss" as const,
+ content: ".dashboard { color: red; }",
+ revision: 4,
+ outdated: false,
+ };
+ const updated = { ...current, revision: 5, outdated: true };
+ vi.spyOn(client, "get")
+ .mockResolvedValueOnce(current as never)
+ .mockResolvedValueOnce(updated as never);
+ mocks.update.mockResolvedValue(updated);
+
+ await expect(client.toggleOutdated(current.id, true)).resolves.toMatchObject({
+ revision: 5,
+ outdated: true,
+ });
+ expect(mocks.update).toHaveBeenCalledWith(current.id, {
+ outdated: true,
+ expectedRevision: 4,
+ });
+ });
+
test("uses PocketBase filtering and pagination when the listing view is current", async () => {
mocks.getList.mockResolvedValue({
page: 1,
@@ -216,6 +247,16 @@ describe("WorkshopBackend", () => {
expect(result.items).toHaveLength(1);
expect(mocks.getFullList).toHaveBeenCalledTimes(2);
});
+
+ test("replaces low-level network errors with an actionable Workshop outage message", async () => {
+ mocks.getList.mockRejectedValueOnce(new Error("NetworkError when attempting to fetch resource."));
+
+ const client = new WorkshopBackend("https://workshop.example.com");
+
+ await expect(client.list({ type: "customWidget" })).rejects.toThrow(
+ "Homarr Workshop seems to be unavailable right now. Try again in a moment.",
+ );
+ });
});
function listingRecord() {
diff --git a/packages/workshop/src/schema.spec.ts b/packages/workshop/src/schema.spec.ts
index 68cb85cd0..a0b807f12 100644
--- a/packages/workshop/src/schema.spec.ts
+++ b/packages/workshop/src/schema.spec.ts
@@ -2,7 +2,35 @@ import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
-import { validateWorkshopWidget, workshopSubmissionInputSchema, workshopSubmissionSummarySchema } from "./schema";
+import {
+ normalizeHttpUrl,
+ resolveHomarrUrlConfig,
+ validateWorkshopWidget,
+ workshopSubmissionInputSchema,
+ workshopSubmissionSummarySchema,
+} from "./schema";
+
+describe("Workshop URL configuration", () => {
+ test("normalizes the URL contract and derives Workshop defaults", () => {
+ expect(
+ resolveHomarrUrlConfig({
+ homarrWebsiteUrl: "https://docs.example.com/",
+ workshopApiUrl: "https://api.example.com///",
+ }),
+ ).toEqual({
+ homarrWebsiteUrl: "https://docs.example.com",
+ workshopApiUrl: "https://api.example.com",
+ workshopWebUrl: "https://docs.example.com/workshop",
+ });
+ });
+
+ test("rejects unsafe or ambiguous public URLs", () => {
+ expect(() => normalizeHttpUrl("file:///tmp/workshop", "WORKSHOP_API_URL")).toThrow("HTTP or HTTPS");
+ expect(() => normalizeHttpUrl("https://user:secret@example.com", "WORKSHOP_API_URL")).toThrow("credentials");
+ expect(() => normalizeHttpUrl("https://example.com/?token=secret", "WORKSHOP_API_URL")).toThrow("query string");
+ expect(() => normalizeHttpUrl("\nhttps://example.com", "WORKSHOP_API_URL")).toThrow("control characters");
+ });
+});
describe("Workshop widget validation", () => {
test("accepts a canonical credential-free widget", () => {
@@ -28,7 +56,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: {
diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts
index 0aea69d41..94cd36151 100644
--- a/packages/workshop/src/schema.ts
+++ b/packages/workshop/src/schema.ts
@@ -1,9 +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 WORKSHOP_API_URL = "https://homarr.dev";
-export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
+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;
@@ -11,6 +16,58 @@ export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
+export interface HomarrUrlConfig {
+ homarrWebsiteUrl: string;
+ workshopApiUrl: string;
+ workshopWebUrl: string;
+}
+
+export interface HomarrUrlConfigInput {
+ homarrWebsiteUrl?: string;
+ workshopApiUrl?: string;
+ workshopWebUrl?: string;
+}
+
+export function normalizeHttpUrl(value: string, variableName: string): string {
+ if (value !== value.trim() || hasAsciiControl(value)) {
+ throw new Error(`${variableName} must not include surrounding whitespace or control characters`);
+ }
+
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new Error(`${variableName} must be a valid absolute HTTP(S) URL`);
+ }
+
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error(`${variableName} must use HTTP or HTTPS`);
+ }
+ if (url.username || url.password) {
+ throw new Error(`${variableName} must not include credentials`);
+ }
+ if (url.search || url.hash) {
+ throw new Error(`${variableName} must not include a query string or fragment`);
+ }
+
+ return url.toString().replace(/\/+$/u, "");
+}
+
+function hasAsciiControl(value: string): boolean {
+ return [...value].some((character) => {
+ const code = character.codePointAt(0) ?? 0;
+ return code <= 31 || code === 127;
+ });
+}
+
+export function resolveHomarrUrlConfig(input: HomarrUrlConfigInput = {}): HomarrUrlConfig {
+ const homarrWebsiteUrl = normalizeHttpUrl(input.homarrWebsiteUrl ?? HOMARR_WEBSITE_URL, "HOMARR_WEBSITE_URL");
+ const workshopApiUrl = normalizeHttpUrl(input.workshopApiUrl ?? homarrWebsiteUrl, "WORKSHOP_API_URL");
+ const workshopWebUrl = normalizeHttpUrl(input.workshopWebUrl ?? `${homarrWebsiteUrl}/workshop`, "WORKSHOP_WEB_URL");
+
+ return { homarrWebsiteUrl, workshopApiUrl, workshopWebUrl };
+}
+
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
export const workshopSubmissionTypeSchema = z.enum(["customWidget", "customCss"]);
export type WorkshopSubmissionType = z.infer;