commit e68e36d4e38cdff13f5357a14668df5f7866b977
parent 11ed816a57bd47a1d9ae0fa145e556eed4480fa8
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Sat, 21 Feb 2026 14:05:25 -0700
mods can add plugins now
Diffstat:
6 files changed, 44 insertions(+), 36 deletions(-)
diff --git a/CLEAN_INSTALL/docs/PLUGINS.md b/CLEAN_INSTALL/docs/PLUGINS.md
@@ -2,11 +2,11 @@
This is the **minimal plugin system** used to ship optional features without forking the core app.
-Plugins are **trusted code**: the owner installs them, and they can run both client-side and server-side logic.
+Plugins are **trusted code**: moderators/owners install them, and they can run both client-side and server-side logic.
-## Install / manage (Owner UI)
+## Install / manage (Moderator/Owner UI)
-1. Sign in as the `owner`.
+1. Sign in as `owner` or `moderator`.
2. Open the **Instance** panel in the left sidebar.
3. Under **Plugins**:
- Upload a plugin `.zip` (must contain a `plugin.json` manifest).
diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js
@@ -5771,8 +5771,12 @@ function isOwnerUser() {
return Boolean(loggedInUser && loggedInRole === "owner");
}
+function canManagePlugins() {
+ return Boolean(loggedInUser && (loggedInRole === "owner" || loggedInRole === "moderator"));
+}
+
function renderPluginsAdminHtml() {
- if (!isOwnerUser()) return `<div class="muted small">Owner only.</div>`;
+ if (!canManagePlugins()) return `<div class="muted small">Moderator/owner only.</div>`;
const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
const listHtml = !plugins.length
@@ -5804,7 +5808,7 @@ function renderPluginsAdminHtml() {
})
.join("");
return `
- <div class="small muted">Owner-only. Install optional plugins to extend your instance.</div>
+ <div class="small muted">Moderator/owner only. Install optional plugins to extend your instance.</div>
<div class="pluginInstallRow" style="margin-top:10px">
<input data-pluginzip="1" type="file" accept=".zip,application/zip" />
<button data-plugininstall="1" class="ghost" type="button">Install</button>
@@ -9974,7 +9978,7 @@ modBodyEl?.addEventListener("click", (e) => {
const pluginReloadBtn = e.target.closest("button[data-pluginreload]");
if (pluginReloadBtn) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
pluginAdminBusy = true;
pluginAdminStatus = "Reloading plugins...";
renderModPanel();
@@ -9984,7 +9988,7 @@ modBodyEl?.addEventListener("click", (e) => {
const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]");
if (pluginUninstallBtn) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase();
if (!id) return;
const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`);
@@ -9998,7 +10002,7 @@ modBodyEl?.addEventListener("click", (e) => {
const pluginInstallBtn = e.target.closest("button[data-plugininstall]");
if (pluginInstallBtn) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null;
const file = input?.files && input.files[0] ? input.files[0] : null;
if (!file) {
@@ -10293,7 +10297,7 @@ modBodyEl?.addEventListener("change", (e) => {
const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]");
if (toggle) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase();
if (!id) return;
const enabled = Boolean(toggle.checked);
@@ -11182,7 +11186,7 @@ function onWsMessage(evt) {
if (msg.type === "permissionDenied") {
const m = msg.message || "Permission denied.";
- if (/owner access required/i.test(m)) {
+ if (/(owner|moderator) access required/i.test(m)) {
pluginAdminStatus = m;
pluginAdminBusy = false;
pluginEnableInFlight.clear();
diff --git a/CLEAN_INSTALL/server.js b/CLEAN_INSTALL/server.js
@@ -3205,8 +3205,8 @@ async function handlePluginInstall(req, res, url) {
return true;
}
const username = getSessionUserFromRequest(req);
- if (!username || !hasRole(username, ROLE_OWNER)) {
- sendJson(res, 403, { error: "Owner access required." });
+ if (!username || !hasRole(username, ROLE_MODERATOR)) {
+ sendJson(res, 403, { error: "Moderator access required." });
return true;
}
@@ -5400,8 +5400,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginSetEnabled") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_OWNER)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
return;
}
const id = normalizePluginId(msg.id || msg.pluginId || "");
@@ -5425,8 +5425,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginUninstall") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_OWNER)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
return;
}
const id = normalizePluginId(msg.id || msg.pluginId || "");
@@ -5455,8 +5455,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginReload") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_OWNER)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
return;
}
loadPluginsFromDisk();
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
@@ -2,7 +2,7 @@
This is the **minimal plugin system** used to ship optional features without forking the core app.
-Plugins are **trusted code**: the owner installs them, and they can run both client-side and server-side logic.
+Plugins are **trusted code**: moderators/owners install them, and they can run both client-side and server-side logic.
## Included plugins at a glance
@@ -17,9 +17,9 @@ Use this quick reference when packaging releases or deciding what to enable by d
| `directory-server` | Public directory endpoint + moderation queue for listings (`/api/plugins/directory-server/list`) | **Directory host instance only** | Acts as the central directory service. |
| `directory-publisher` | Sends this instance announcement payloads to a directory server | Any instance that wants listing | Configure directory URL + instance URL/name, then publish. |
-## Install / manage (Owner UI)
+## Install / manage (Moderator/Owner UI)
-1. Sign in as the `owner`.
+1. Sign in as `owner` or `moderator`.
2. Open the **Instance** panel in the left sidebar.
3. Under **Plugins**:
- Upload a plugin `.zip` (must contain a `plugin.json` manifest).
diff --git a/public/app.js b/public/app.js
@@ -5771,8 +5771,12 @@ function isOwnerUser() {
return Boolean(loggedInUser && loggedInRole === "owner");
}
+function canManagePlugins() {
+ return Boolean(loggedInUser && (loggedInRole === "owner" || loggedInRole === "moderator"));
+}
+
function renderPluginsAdminHtml() {
- if (!isOwnerUser()) return `<div class="muted small">Owner only.</div>`;
+ if (!canManagePlugins()) return `<div class="muted small">Moderator/owner only.</div>`;
const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
const listHtml = !plugins.length
@@ -5804,7 +5808,7 @@ function renderPluginsAdminHtml() {
})
.join("");
return `
- <div class="small muted">Owner-only. Install optional plugins to extend your instance.</div>
+ <div class="small muted">Moderator/owner only. Install optional plugins to extend your instance.</div>
<div class="pluginInstallRow" style="margin-top:10px">
<input data-pluginzip="1" type="file" accept=".zip,application/zip" />
<button data-plugininstall="1" class="ghost" type="button">Install</button>
@@ -9974,7 +9978,7 @@ modBodyEl?.addEventListener("click", (e) => {
const pluginReloadBtn = e.target.closest("button[data-pluginreload]");
if (pluginReloadBtn) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
pluginAdminBusy = true;
pluginAdminStatus = "Reloading plugins...";
renderModPanel();
@@ -9984,7 +9988,7 @@ modBodyEl?.addEventListener("click", (e) => {
const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]");
if (pluginUninstallBtn) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase();
if (!id) return;
const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`);
@@ -9998,7 +10002,7 @@ modBodyEl?.addEventListener("click", (e) => {
const pluginInstallBtn = e.target.closest("button[data-plugininstall]");
if (pluginInstallBtn) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null;
const file = input?.files && input.files[0] ? input.files[0] : null;
if (!file) {
@@ -10293,7 +10297,7 @@ modBodyEl?.addEventListener("change", (e) => {
const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]");
if (toggle) {
- if (!isOwnerUser()) return;
+ if (!canManagePlugins()) return;
const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase();
if (!id) return;
const enabled = Boolean(toggle.checked);
@@ -11182,7 +11186,7 @@ function onWsMessage(evt) {
if (msg.type === "permissionDenied") {
const m = msg.message || "Permission denied.";
- if (/owner access required/i.test(m)) {
+ if (/(owner|moderator) access required/i.test(m)) {
pluginAdminStatus = m;
pluginAdminBusy = false;
pluginEnableInFlight.clear();
diff --git a/server.js b/server.js
@@ -3406,8 +3406,8 @@ async function handlePluginInstall(req, res, url) {
return true;
}
const username = getSessionUserFromRequest(req);
- if (!username || !hasRole(username, ROLE_OWNER)) {
- sendJson(res, 403, { error: "Owner access required." });
+ if (!username || !hasRole(username, ROLE_MODERATOR)) {
+ sendJson(res, 403, { error: "Moderator access required." });
return true;
}
@@ -5608,8 +5608,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginSetEnabled") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_OWNER)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
return;
}
const id = normalizePluginId(msg.id || msg.pluginId || "");
@@ -5633,8 +5633,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginUninstall") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_OWNER)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
return;
}
const id = normalizePluginId(msg.id || msg.pluginId || "");
@@ -5663,8 +5663,8 @@ wss.on("connection", (ws, req) => {
if (msg.type === "pluginReload") {
const actor = ws?.user?.username;
- if (!actor || !hasRole(actor, ROLE_OWNER)) {
- ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
return;
}
loadPluginsFromDisk();