commit 666b227a62281f0ec872e0b848bbe87d1aa6535b
parent ffd926c06cb492ccdf199450475bcd886b1ac303
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Sun, 22 Feb 2026 15:36:57 -0700
fixed map plugins avatar preset management bug
Server fix (delete/apply/update by normalized ID): server.js (line 641)
Added findAvatarPresetIndexById() and now use it for:
upsertAvatarPreset
deleteAvatarPreset
applyAvatarPreset
This avoids silent mismatches when IDs aren’t perfectly aligned.
Server load hardening: server.js (line 843)
Added dedupe on preset IDs when loading from disk so corrupted/duplicate entries don’t poison behavior.
Better delete feedback: server.js (line 1450)
If a preset ID isn’t found, server now returns Preset not found. instead of silently doing nothing.
Client selection fix: client.js (line 5528)
After preset list refresh, if the selected preset no longer exists (e.g., deleted), it auto-selects the first available preset instead of leaving selection empty.
Synced runtime copies:
server.js
client.js
Validation:
server.js
client.js
Diffstat:
3 files changed, 128 insertions(+), 7 deletions(-)
diff --git a/docs/SECURITY_UPGRADE_PRIVATE.md b/docs/SECURITY_UPGRADE_PRIVATE.md
@@ -0,0 +1,105 @@
+# PRIVATE: Security Upgrade Plan (Core)
+
+Last updated: 2026-02-22
+
+This document is for internal planning and prioritization.
+
+## Scope
+
+Core server and first-party client:
+- `server.js`
+- `public/app.js`
+- auth/session, plugin loading, WebSocket abuse controls, upload surface, role controls
+
+## Risk priorities
+
+### P0 (immediate)
+
+1. Tighten plugin trust boundary
+ - Move plugin install/enable/uninstall/reload from moderator to admin/owner.
+ - Add plugin signing + trusted publisher checks.
+ - Add optional `PLUGIN_INSTALL_DISABLED=1` hard lock for production.
+
+2. Add message flood controls
+ - Add rate limits for:
+ - `newPost`
+ - `chatMessage`
+ - `dmSend`
+ - `dmSendMod`
+ - Include server-side hard caps + backoff events.
+
+3. Raise password baseline
+ - Increase minimum from 4 to 10+.
+ - Add optional strong policy mode (length + complexity).
+ - Add migration note for existing instances.
+
+### P1 (near-term)
+
+4. Move session storage out of `localStorage`
+ - Switch to secure session cookies:
+ - `HttpOnly`
+ - `Secure`
+ - `SameSite=Lax` (or `Strict` if compatible)
+ - Keep token rotation and invalidation.
+
+5. WebSocket origin + payload hardening
+ - Add explicit origin allowlist env (`WS_ORIGIN_ALLOWLIST`).
+ - Reject unknown origins at WS handshake.
+ - Set explicit WS max payload and close on overflow.
+
+6. TURN credential hardening
+ - Move from static TURN credentials to short-lived credentials.
+ - Add relay abuse monitoring and quotas.
+
+### P2 (mid-term)
+
+7. Security headers and deployment defaults
+ - Add HSTS when HTTPS is confirmed.
+ - Re-check CSP for least privilege after plugin API updates.
+
+8. Audit and tamper logging
+ - Security log stream for:
+ - role changes
+ - plugin install/uninstall
+ - repeated failed auth
+ - unusual upload spikes
+
+9. Safe plugin execution model
+ - Evaluate plugin sandbox strategy:
+ - isolate process
+ - permission-gated API
+ - no raw filesystem/process access by default
+
+## New role model update (current change)
+
+Added base role:
+- `admin` (between `moderator` and `owner`)
+
+Intent:
+- Admin can manage plugins and access owner-level views.
+- Owner remains final authority for destructive or ownership-sensitive operations.
+
+## Implementation checkpoints
+
+### Phase A (this release train)
+- [x] Add `admin` role in core role hierarchy.
+- [x] Restrict plugin management to admin/owner.
+- [ ] Add RL buckets for post/chat/dm sends.
+
+### Phase B
+- [ ] Cookie-based session migration.
+- [ ] WS origin allowlist.
+- [ ] WS payload limit hard cap.
+
+### Phase C
+- [ ] Plugin signing and trust policy.
+- [ ] Optional plugin sandbox architecture draft.
+
+## Validation checklist per phase
+
+- Unit/smoke test auth flows.
+- Verify moderation matrix (member/mod/admin/owner).
+- Load test chat + DM flood controls.
+- Confirm plugin lifecycle still works for admin/owner only.
+- Verify no regression in mobile/desktop UX around auth and chat.
+
diff --git a/plugins_dev/maps/client.js b/plugins_dev/maps/client.js
@@ -5528,7 +5528,7 @@
avatarPresets = normalizeAvatarPresetList(msg?.presets);
avatarPresetsCanManage = Boolean(msg?.canManage);
if (!avatarPresets.some((preset) => preset.id === avatarPresetSelectedId)) {
- avatarPresetSelectedId = "";
+ avatarPresetSelectedId = avatarPresets[0]?.id || "";
}
if (mode === "map" && avatarEditorOpen) renderMapView();
return;
diff --git a/plugins_dev/maps/server.js b/plugins_dev/maps/server.js
@@ -646,6 +646,12 @@ module.exports = function init(api) {
ws.send(JSON.stringify({ type: "plugin:maps:avatarPresets", presets, canManage }));
}
+ function findAvatarPresetIndexById(rawId) {
+ const targetId = normId(rawId || "");
+ if (!targetId) return -1;
+ return avatarPresets.findIndex((preset) => normId(preset?.id || "") === targetId);
+ }
+
function sanitizeMapChatText(text) {
const raw = typeof text === "string" ? text : "";
return raw.replace(/\s+/g, " ").trim().slice(0, 420);
@@ -840,9 +846,12 @@ module.exports = function init(api) {
}
customMaps = next;
const nextPresets = [];
+ const seenPresetIds = new Set();
for (const rawPreset of presetList) {
const preset = normalizeAvatarPreset(rawPreset || {}, rawPreset?.updatedBy || rawPreset?.createdBy || "");
if (!preset) continue;
+ if (seenPresetIds.has(preset.id)) continue;
+ seenPresetIds.add(preset.id);
nextPresets.push(preset);
}
avatarPresets = nextPresets;
@@ -1411,7 +1420,7 @@ module.exports = function init(api) {
ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Invalid avatar preset." }));
return;
}
- const idx = avatarPresets.findIndex((preset) => preset.id === normalized.id);
+ const idx = findAvatarPresetIndexById(normalized.id);
if (idx >= 0) {
const prior = avatarPresets[idx];
avatarPresets[idx] = {
@@ -1449,9 +1458,12 @@ module.exports = function init(api) {
}
const id = normId(msg?.id || "");
if (!id) return;
- const before = avatarPresets.length;
- avatarPresets = avatarPresets.filter((preset) => preset.id !== id);
- if (avatarPresets.length === before) return;
+ const idx = findAvatarPresetIndexById(id);
+ if (idx < 0) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset not found." }));
+ return;
+ }
+ avatarPresets.splice(idx, 1);
try {
saveCustomMapsToDisk();
} catch {
@@ -1467,8 +1479,12 @@ module.exports = function init(api) {
if (!username) return;
const id = normId(msg?.id || "");
if (!id) return;
- const preset = avatarPresets.find((item) => item.id === id);
- if (!preset) return;
+ const idx = findAvatarPresetIndexById(id);
+ if (idx < 0) {
+ ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset not found." }));
+ return;
+ }
+ const preset = avatarPresets[idx];
if (!preset.published && !canManageAvatarPresets(ws)) {
ws.send(JSON.stringify({ type: "plugin:maps:error", message: "Preset unavailable." }));
return;