glassframe-protocol 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,106 @@ lives in `src/core/VersionInfo.js` (`VersionInfo.info()` /
14
14
  `VersionInfo.banner()`), so code can read the current version without
15
15
  parsing this file.
16
16
 
17
+ ## [2.1.0] - 2026-08-01
18
+
19
+ Additive - nothing from 2.0.0's API changes. New detections, a plugin
20
+ system, per-guild customization, and an owner-only cross-server dashboard.
21
+
22
+ ### Added - security detections
23
+
24
+ - **Dangerous role grants to a member.** The existing permission-grant
25
+ watchdog only caught a role's own permissions changing (`RoleUpdate`).
26
+ It missed the other half: someone with `ManageRoles` handing an
27
+ already-dangerous role directly to a member (`MemberRoleUpdate`) without
28
+ ever touching the role's definition. Now watched, gated the same way -
29
+ only flagged when the executor isn't recognized trusted staff.
30
+ - **Channel permission-overwrite abuse.** A nuke doesn't have to delete
31
+ anything - locking @everyone out of a public channel, or opening a
32
+ private one up to @everyone, does equivalent damage through permission
33
+ overwrites. Only @everyone's own overwrite is watched; a role- or
34
+ member-specific overwrite change is routine administration and ignored.
35
+ - **A dedicated @everyone/@here guard** in Basic Security, weighted well
36
+ above regular mention spam (a single mass ping reaches every member at
37
+ once) and skipped for recognized trusted staff, so a real admin's
38
+ announcement isn't flagged.
39
+ - **Raid-recruitment language is now actually used.** `raidCallout` scoring
40
+ existed in the NLP engine already but was never wired into
41
+ `BasicSecurityLayer`'s real decision logic - fixed. Also added a specific
42
+ invite-link + raid-language combo check (the "join this server to raid
43
+ with us" pattern), and expanded the raid lexicon to catch common word
44
+ forms ("raiding", "raids") that the bare-verb-only version missed.
45
+
46
+ ### Added - extensibility
47
+
48
+ - **`frame.registerLayer(name, layerInstance)`** - add a custom security
49
+ layer to the same shared pipeline the built-in four use. Must extend
50
+ `core/Layer`; gets per-guild enable/disable, state persistence, and
51
+ `!gf status`/`!gf metrics` visibility for free, since those now iterate
52
+ `frame.layers` dynamically instead of a fixed list. Does not get an
53
+ automatic button on the 5-button panel (that stays fixed at 5).
54
+ - **`frame.registerAction(name, handler)`** - add a punishment action
55
+ beyond ban/kick/timeout/quarantine, referenceable from
56
+ `config.punishment.ladder`. Runs through the same action queue as the
57
+ built-in ones.
58
+ - **Per-guild custom prefix** - `!gf prefix set <newPrefix>` /
59
+ `!gf prefix reset`. `config.prefix` always still works everywhere as a
60
+ fallback; an overlapping custom/default pair resolves by trying the
61
+ longer one first.
62
+ - **Per-layer log channel routing** - `getLogChannel(guild, layerName)` now
63
+ receives the reporting layer's name as an optional second argument, so
64
+ different layers can route to different channels. Fully backward
65
+ compatible - a `getLogChannel` that only takes one parameter keeps
66
+ working unchanged.
67
+
68
+ ### Added - the owner-only engine dashboard
69
+
70
+ - **`!gf engine`** - a bot-wide, cross-server dashboard: layer adoption
71
+ across every server, the busiest servers by event volume, AI usage
72
+ (Groq key pool/cooldowns/call volume), queue and cache health, and a live
73
+ feed of the last several things logged anywhere. See `docs/ENGINE.md`.
74
+ Deliberately not listed in `!gf help` - a per-server admin shouldn't know
75
+ it exists, let alone see data about other servers.
76
+ - **Its own permission model**, separate from every other command:
77
+ `options.owners` (a list of Discord user IDs) is required regardless, and
78
+ an optional `config.engine.password` is a second factor on the initial
79
+ command only - page-navigation clicks re-check `isOwner()` but don't
80
+ re-prompt for the password. Wrong guesses count toward a lockout
81
+ (`config.engine.maxAttempts`, `config.engine.lockoutMs`), and the command
82
+ message is deleted immediately either way so the password doesn't sit
83
+ visible in channel history.
84
+ - **5 pages** (Overview, Servers, AI, Performance, Activity Log), navigated
85
+ with a row of tab buttons - this is a second interactive surface,
86
+ entirely separate from the 5-button panel and the metrics command's own
87
+ button.
88
+ - `frame.getEngineReport()` - the raw data behind the dashboard, if you
89
+ want it for your own tooling instead of the rendered message.
90
+ - `PerformanceMonitor.recordEvent(name, guildId)` now optionally tracks
91
+ per-guild activity (`topGuildsByActivity()`) alongside the existing
92
+ by-name counters.
93
+ - `SmartLogger` keeps a bounded in-memory ring buffer (`getRecentLogs()`,
94
+ last 30) of recent events across every guild, powering the Activity Log
95
+ page.
96
+
97
+ ### Changed
98
+
99
+ - `!gf help` reorganized into sections (Getting started / Security tools /
100
+ Diagnostics / Customization) with a one-line explanation of what each
101
+ command actually does, instead of a flat list of syntax.
102
+ - `!gf status` now explains what each layer actually watches for, not just
103
+ whether it's on, and iterates every registered layer (including custom
104
+ ones) instead of a fixed four.
105
+ - `!gf metrics` and the global engine view now carry a short plain-language
106
+ explanation under every number - what it means, and whether it's
107
+ generally worth a closer look.
108
+
109
+ ### Fixed
110
+
111
+ - `raidCallout` NLP scoring was computed but never checked anywhere.
112
+ - The default `raidCalloutThreshold` (0.5) was miscalibrated against how
113
+ the scoring function actually behaves - lowered to 0.3, validated against
114
+ both a real raid-recruitment example and known-innocuous messages with
115
+ margin on both sides.
116
+
17
117
  ## [2.0.0] - 2026-08-01
18
118
 
19
119
  **Breaking.** Layer activation and the whitelist were accidentally global
@@ -123,11 +123,28 @@ Nobody needs this guide again after setup. Server admins use:
123
123
  | `!gf whitelist add/remove <userId>` | Exempt a user from punitive action in this server |
124
124
  | `!gf help` | Lists all of the above |
125
125
 
126
+ ## Step 6 (optional) - Your own bot-wide dashboard
127
+
128
+ If you run the bot yourself, `!gf engine` gives you a cross-server view
129
+ (busiest server, AI usage, a live activity feed) that regular server admins
130
+ can't see:
131
+
132
+ ```js
133
+ const frame = new GlassFrame(client, {
134
+ getLogChannel: /* ... */,
135
+ owners: ["your-discord-user-id"]
136
+ });
137
+ ```
138
+
139
+ See `docs/ENGINE.md` for the optional password/lockout system and what
140
+ each of its 5 pages shows.
141
+
126
142
  ## Going deeper
127
143
 
128
144
  - `README.md` - full feature overview
129
145
  - `docs/PROTOCOL_LAYERS.md` - how a signal becomes an action
130
146
  - `docs/STATE.md` - per-server layer/whitelist state and persistence
147
+ - `docs/ENGINE.md` - the owner-only bot-wide dashboard
131
148
  - `docs/CACHE_ARCHITECTURE.md` - every internal cache and its TTL
132
149
  - `docs/PERFORMANCE.md` - the bounded-concurrency queues and `!gf metrics`
133
150
  - `docs/COMMANDS.md` - full command/button reference
package/README.md CHANGED
@@ -80,8 +80,10 @@ the 5-button control panel and arm layers from there instead of in code.
80
80
 
81
81
  ## The four layers
82
82
 
83
- - **Basic Security** - spam rate, duplicate flood, mention spam, local NLP
84
- scam/phishing scoring, and link checks via `PhishingDatabase` (a
83
+ - **Basic Security** - spam rate, duplicate flood, mention spam, a
84
+ dedicated trust-aware @everyone/@here guard, local NLP scam/phishing/
85
+ raid-recruitment scoring (including a specific invite-link +
86
+ raid-language combo check), and link checks via `PhishingDatabase` (a
85
87
  server-editable blocklist plus raw-IP/punycode/brand-look-alike/shortener
86
88
  heuristics - `!gf phishing add/remove/list`).
87
89
  - **AntiRaid** - join-velocity lockdown with automatic verification-level
@@ -90,12 +92,15 @@ the 5-button control panel and arm layers from there instead of in code.
90
92
  catches a raid trickling in too slowly to trip the rate counter.
91
93
  - **AntiNuke** - audit-log burst detection across channels, roles, bans,
92
94
  kicks, webhooks, emoji, and stickers per executor; a dangerous
93
- permission-grant watchdog (catches a role quietly gaining Administrator
94
- the moment it happens, with optional auto-revert); a server-identity
95
- watchdog (name/icon/vanity URL changes); an invite-abuse watchdog
96
- (unrestricted invites from non-staff); and webhook-flood containment
97
- (deletes an abusive webhook and traces it back to its creator, since
98
- webhook messages carry no guild member for other layers to see).
95
+ permission-grant watchdog covering both a role's own permissions
96
+ changing *and* a member being quietly handed an already-dangerous role
97
+ (with optional auto-revert); a channel permission-overwrite watchdog
98
+ (catches @everyone getting locked out of, or let into, a channel without
99
+ anything being deleted); a server-identity watchdog (name/icon/vanity URL
100
+ changes); an invite-abuse watchdog (unrestricted invites from non-staff);
101
+ and webhook-flood containment (deletes an abusive webhook and traces it
102
+ back to its creator, since webhook messages carry no guild member for
103
+ other layers to see).
99
104
  - **AI Moderation** (optional, off by default) - Groq-powered second opinion
100
105
  for messages the local NLP scores as "gray zone": not clean, not clearly
101
106
  over threshold. Supports a pool of API keys with automatic per-key
@@ -104,7 +109,8 @@ the 5-button control panel and arm layers from there instead of in code.
104
109
 
105
110
  Every layer starts disabled, per guild. Nothing runs until you list it in
106
111
  `autoStart`, call `frame.enableLayer(name, guildId)`, or arm it from the
107
- control panel.
112
+ control panel. Add your own layer alongside these four with
113
+ `frame.registerLayer()` - see "Extending it" below.
108
114
 
109
115
  ## Performance under load
110
116
 
@@ -128,17 +134,37 @@ for human review, never auto-banned), and only if there isn't already an
128
134
  open case for that member from a moment ago. Full write-up in
129
135
  `docs/PROTOCOL_LAYERS.md`.
130
136
 
137
+ ## Extending it
138
+
139
+ - **`frame.registerLayer(name, layerInstance)`** - add your own security
140
+ layer to the same pipeline the built-in four use (per-guild enable/state/
141
+ persistence come free from extending `core/Layer`). Doesn't get a button
142
+ on the panel automatically - the panel stays fixed at exactly 5.
143
+ - **`frame.registerAction(name, handler)`** - add a custom punishment
144
+ action beyond ban/kick/timeout/quarantine, referenceable from
145
+ `config.punishment.ladder`.
146
+ - **`!gf prefix set <newPrefix>`** - each server can run its own prefix;
147
+ `config.prefix` always still works everywhere as a fallback.
148
+ - **`getLogChannel(guild, layerName)`** - route different layers to
149
+ different channels, if you want; the second parameter is optional and
150
+ backward compatible.
151
+ - **`!gf engine`** - an owner-only, bot-wide dashboard (busiest server, AI
152
+ usage, live cross-server activity feed), gated separately from every
153
+ other command and not listed in `!gf help`. See `docs/ENGINE.md`.
154
+
131
155
  ## Docs
132
156
 
133
157
  - `GETTING_STARTED.md` - adding GlassFrame to a bot you already have running.
134
158
  - `docs/PROTOCOL_LAYERS.md` - full architecture: containment vs. punishment,
135
- how a signal becomes an action, how to add a fifth layer.
159
+ how a signal becomes an action, `registerLayer`/`registerAction`.
136
160
  - `docs/STATE.md` - per-guild layer/whitelist state and how persistence
137
161
  across restarts works.
138
162
  - `docs/CACHE_ARCHITECTURE.md` - every cache in the library, its key shape,
139
163
  its TTL, and how to turn on cache-level debug logging.
140
164
  - `docs/PERFORMANCE.md` - the bounded-concurrency queues and internal
141
165
  metrics collector, and what `!gf metrics` shows.
166
+ - `docs/ENGINE.md` - the owner-only bot-wide dashboard: the password/lockout
167
+ system and what each of its 5 pages shows.
142
168
  - `docs/COMMANDS.md` - the full prefix command reference and the control
143
169
  panel's five buttons.
144
170
  - `docs/PUBLISHING.md` - publishing this package to npm, and what actually
package/dist/config.js CHANGED
@@ -44,7 +44,7 @@ module.exports = {
44
44
  basicSecurity: {
45
45
  spam: { windowMs: 6000, messageThreshold: 6, duplicateThreshold: 4 },
46
46
  mentionSpam: { maxMentionsPerMessage: 8 },
47
- nlp: { scamScoreThreshold: 0.62, phishingScoreThreshold: 0.55 },
47
+ nlp: { scamScoreThreshold: 0.62, phishingScoreThreshold: 0.55, raidCalloutThreshold: 0.3 },
48
48
  // Seed domains for PhishingDatabase (src/security/PhishingDatabase.js).
49
49
  // Empty by default - populate with what you actually see via
50
50
  // `!gf phishing add <domain>` rather than shipping unverified blocklists.
@@ -83,6 +83,10 @@ module.exports = {
83
83
  // A brand-new invite with no expiry and no use limit, created by anyone
84
84
  // other than the owner, is worth a look the moment it's created.
85
85
  watchInviteAbuse: true,
86
+ // Watches @everyone's channel permission overwrites specifically - a
87
+ // channel getting locked out for everyone, or a private channel getting
88
+ // opened to everyone, is a nuke technique that doesn't delete anything.
89
+ watchOverwriteAbuse: true,
86
90
  // A webhook that starts pushing a burst of messages within minutes of
87
91
  // being created is a classic "nuke via webhook" pattern - this catches
88
92
  // it even though the messages themselves look like normal messageCreate
@@ -134,5 +138,20 @@ module.exports = {
134
138
  // Default for GlassFrame's debug flag - logs every cache SET/HIT/MISS/EXPIRE to the console.
135
139
  // See docs/CACHE_ARCHITECTURE.md. Overridden by the `debug` constructor option if provided.
136
140
  debug: false
141
+ },
142
+
143
+ // The owner-only `!gf engine` dashboard - see docs/ENGINE.md. Being an
144
+ // owner (options.owners) is required no matter what; a password is an
145
+ // optional second factor on top of that for the initial command only -
146
+ // page-navigation button clicks are still gated by isOwner() but don't
147
+ // re-prompt for the password.
148
+ engine: {
149
+ // Set via an environment variable in your own bot, e.g.
150
+ // process.env.GLASSFRAME_ENGINE_PASSWORD - never hardcode a real
151
+ // password into a committed config.js. Leave null to skip the second
152
+ // factor and rely on the owners list alone.
153
+ password: null,
154
+ maxAttempts: 3,
155
+ lockoutMs: 1000 * 60 * 10
137
156
  }
138
157
  };
@@ -23,7 +23,7 @@ const AntiNukeLayer = require("./layers/AntiNukeLayer");
23
23
  const AIModerationLayer = require("./layers/AIModerationLayer");
24
24
 
25
25
  const PrefixRouter = require("./commands/PrefixRouter");
26
- const { buildPanel, buildMetricsMessage } = require("./ui/ControlPanel");
26
+ const { buildPanel, buildMetricsMessage, buildEnginePage } = require("./ui/ControlPanel");
27
27
 
28
28
  const LAYER_NAMES = ["basicSecurity", "antiRaid", "antiNuke", "aiModeration"];
29
29
 
@@ -54,8 +54,15 @@ class GlassFrame extends EventEmitter {
54
54
  // as autoStart), never overriding a guild's own saved whitelist.
55
55
  this.defaultWhitelist = new Set(options.whitelist || []);
56
56
  this.whitelist = new Map(); // guildId -> Set<userId>
57
+ this.customActions = new Map(); // actionName -> async (member, record) => void, for registerAction()
58
+ this.guildPrefixes = new Map(); // guildId -> custom prefix, for setPrefix()
57
59
  this.panelMessages = new Map(); // messageId -> guildId
58
60
  this.debug = Boolean(options.debug ?? this.config.cache.debug);
61
+ // Bot-owner user IDs - separate from per-guild Manage Server admins,
62
+ // since cross-server data (busiest server, all-guild activity) isn't
63
+ // something a random single server's admin should see about others.
64
+ this.owners = new Set(options.owners || []);
65
+ this.engineAttempts = new Map(); // userId -> { count, lockedUntil }
59
66
 
60
67
  this.threatEngine = new ThreatEngine(this.config, { debug: this.debug });
61
68
  this.roleAnalyzer = new RoleAnalyzer(this.config);
@@ -77,6 +84,7 @@ class GlassFrame extends EventEmitter {
77
84
  roleAnalyzer: this.roleAnalyzer,
78
85
  logger: this.logger,
79
86
  whitelist: this.whitelist,
87
+ customActions: this.customActions,
80
88
  queue: this.actionQueue,
81
89
  performance: this.performance,
82
90
  debug: this.debug
@@ -123,6 +131,40 @@ class GlassFrame extends EventEmitter {
123
131
  return member.permissions.has(PermissionsBitField.Flags.ManageGuild);
124
132
  }
125
133
 
134
+ /** For the owner-only !gf engine dashboard - separate from per-guild isAuthorized(). */
135
+ isOwner(userId) {
136
+ return this.owners.has(userId);
137
+ }
138
+
139
+ /**
140
+ * Second factor on top of isOwner(), only for the initial `!gf engine`
141
+ * command - not required again for page-navigation button clicks, since
142
+ * those are already tied to a real, already-verified Discord identity.
143
+ * Returns true with no config.engine.password set (owner check alone is
144
+ * enough). Locks a user out for engine.lockoutMs after
145
+ * engine.maxAttempts wrong guesses, to make brute-forcing impractical.
146
+ */
147
+ verifyEnginePassword(userId, provided) {
148
+ const cfg = this.config.engine;
149
+ if (!cfg.password) return true;
150
+
151
+ const attempt = this.engineAttempts.get(userId) || { count: 0, lockedUntil: 0 };
152
+ if (Date.now() < attempt.lockedUntil) return false;
153
+
154
+ if (provided === cfg.password) {
155
+ this.engineAttempts.delete(userId);
156
+ return true;
157
+ }
158
+
159
+ attempt.count++;
160
+ if (attempt.count >= cfg.maxAttempts) {
161
+ attempt.lockedUntil = Date.now() + cfg.lockoutMs;
162
+ attempt.count = 0;
163
+ }
164
+ this.engineAttempts.set(userId, attempt);
165
+ return false;
166
+ }
167
+
126
168
  getStatus(guildId) {
127
169
  const out = {};
128
170
  for (const [name, layer] of Object.entries(this.layers)) {
@@ -174,6 +216,50 @@ class GlassFrame extends EventEmitter {
174
216
  };
175
217
  }
176
218
 
219
+ /**
220
+ * Everything about the running process at once, across every guild -
221
+ * what's live, what's fast/slow, which server is generating the most
222
+ * work, and the last several things that happened anywhere. Powers the
223
+ * owner-only `!gf engine` command. This is intentionally NOT reachable
224
+ * by a per-guild admin - see isOwner().
225
+ */
226
+ getEngineReport() {
227
+ const guilds = [...this.client.guilds.cache.values()];
228
+ const layerNames = Object.keys(this.layers);
229
+
230
+ const adoption = {};
231
+ for (const name of layerNames) {
232
+ adoption[name] = guilds.filter((g) => this.layers[name].isEnabled(g.id)).length;
233
+ }
234
+
235
+ const busiest = this.performance.topGuildsByActivity(5).map(([guildId, count]) => {
236
+ const guild = this.client.guilds.cache.get(guildId);
237
+ return { guildId, name: guild ? guild.name : "(left / unknown)", count };
238
+ });
239
+
240
+ const recentLogs = this.logger.getRecentLogs(10).map((entry) => ({
241
+ ...entry,
242
+ guildName: this.client.guilds.cache.get(entry.guildId)?.name || entry.guildName || entry.guildId
243
+ }));
244
+
245
+ return {
246
+ version: VersionInfo.info(),
247
+ uptimeMs: Date.now() - this.performance.startedAt,
248
+ guildCount: guilds.length,
249
+ layerAdoption: adoption,
250
+ metrics: this.getMetrics(),
251
+ busiestGuilds: busiest,
252
+ recentLogs,
253
+ ai: {
254
+ available: this.groqClient.available,
255
+ keyCount: this.groqClient.keys.length,
256
+ keysOnCooldown: this.groqClient.keys.filter((k) => k.cooldownUntil > Date.now()).length,
257
+ callsLastMinute: this.groqClient.callTimestamps.length,
258
+ totalCallsRecorded: this.performance.eventCounts["groqClient.classify"] || 0
259
+ }
260
+ };
261
+ }
262
+
177
263
  enableLayer(name, guildId) {
178
264
  if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
179
265
  if (!guildId) throw new Error("GlassFrame Protocol: enableLayer(name, guildId) requires a guildId - layers are per-guild.");
@@ -188,29 +274,83 @@ class GlassFrame extends EventEmitter {
188
274
  return this;
189
275
  }
190
276
 
191
- /** Snapshots one guild's layer + whitelist state into stateStore. Fire-and-forget by design (never blocks a handler on disk/network I/O). */
277
+ /**
278
+ * Adds your own security layer to the same pipeline the built-in four
279
+ * use - it gets per-guild enable/disable, state persistence, and can
280
+ * report signals to the shared ThreatEngine/PunishmentEngine, all for
281
+ * free. `layerInstance` must extend `core/Layer` (see
282
+ * docs/PROTOCOL_LAYERS.md, "Adding a fifth layer"). Custom layers do NOT
283
+ * get a button on the 5-button panel automatically - toggle them via
284
+ * `frame.enableLayer(name, guildId)` in code, or build your own command.
285
+ */
286
+ registerLayer(name, layerInstance) {
287
+ if (this.layers[name]) throw new Error(`GlassFrame Protocol: a layer named "${name}" already exists`);
288
+ if (typeof layerInstance?.attach !== "function" || typeof layerInstance?.enable !== "function") {
289
+ throw new Error("GlassFrame Protocol: registerLayer() expects an instance extending core/Layer");
290
+ }
291
+ this.layers[name] = layerInstance;
292
+ layerInstance.attach();
293
+ this.emit("layerRegistered", { layer: name });
294
+ return this;
295
+ }
296
+
297
+ /**
298
+ * Adds a custom punishment action beyond the built-in
299
+ * ban/kick/timeout/quarantine, so `config.punishment.ladder` can
300
+ * reference it by name. `handler` is `async (member, record) => {}` -
301
+ * `record` is the case object (tier, trust, reasons, ...). Runs through
302
+ * the same bounded-concurrency action queue as the built-in actions.
303
+ *
304
+ * @example
305
+ * frame.registerAction("addMutedRole", async (member) => {
306
+ * const role = member.guild.roles.cache.find((r) => r.name === "Muted");
307
+ * if (role) await member.roles.add(role);
308
+ * });
309
+ * // config: { punishment: { ladder: { medium: "addMutedRole" } } }
310
+ */
311
+ registerAction(name, handler) {
312
+ if (this.customActions.has(name)) throw new Error(`GlassFrame Protocol: an action named "${name}" is already registered`);
313
+ if (typeof handler !== "function") throw new Error("GlassFrame Protocol: registerAction() expects a function");
314
+ this.customActions.set(name, handler);
315
+ return this;
316
+ }
317
+
318
+ /** The prefix this guild actually uses - its own custom one if set, otherwise config.prefix. */
319
+ getPrefix(guildId) {
320
+ return this.guildPrefixes.get(guildId) || this.config.prefix;
321
+ }
322
+
323
+ /** Sets this guild's own command prefix. config.prefix keeps working everywhere as a fallback, in case a server forgets its custom one. */
324
+ setPrefix(guildId, prefix) {
325
+ this.guildPrefixes.set(guildId, prefix);
326
+ this._persistGuildState(guildId);
327
+ }
328
+
329
+ /** Snapshots one guild's layer + whitelist + prefix state into stateStore. Fire-and-forget by design (never blocks a handler on disk/network I/O). */
192
330
  _persistGuildState(guildId) {
193
331
  const state = {
194
332
  layers: this.getStatus(guildId),
195
- whitelist: [...(this.whitelist.get(guildId) || [])]
333
+ whitelist: [...(this.whitelist.get(guildId) || [])],
334
+ prefix: this.guildPrefixes.get(guildId) || null
196
335
  };
197
336
  Promise.resolve(this.stateStore.set(guildId, state)).catch((err) =>
198
337
  this.emit("warning", { layer: "core", message: `failed to persist state for ${guildId}: ${err.message}` })
199
338
  );
200
339
  }
201
340
 
202
- /** Loads a guild's saved state if one exists; otherwise applies the autoStart defaults (and persists that as the new baseline). */
341
+ /** Loads a guild's saved state if one exists; otherwise applies the autoStart/whitelist defaults (and persists that as the new baseline). */
203
342
  async _restoreGuildState(guildId) {
204
343
  const saved = await this.stateStore.get(guildId);
205
344
 
206
345
  if (saved) {
207
- for (const name of LAYER_NAMES) {
346
+ for (const name of Object.keys(this.layers)) {
208
347
  if (saved.layers?.[name]) this.layers[name].enable(guildId);
209
348
  }
210
349
  for (const userId of saved.whitelist || []) {
211
350
  if (!this.whitelist.has(guildId)) this.whitelist.set(guildId, new Set());
212
351
  this.whitelist.get(guildId).add(userId);
213
352
  }
353
+ if (saved.prefix) this.guildPrefixes.set(guildId, saved.prefix);
214
354
  return;
215
355
  }
216
356
 
@@ -231,6 +371,8 @@ class GlassFrame extends EventEmitter {
231
371
  await this._handlePanelButton(interaction);
232
372
  } else if (interaction.customId.startsWith("gfp_metrics_")) {
233
373
  await this._handleMetricsButton(interaction);
374
+ } else if (interaction.customId.startsWith("gfp_engine_")) {
375
+ await this._handleEngineButton(interaction);
234
376
  }
235
377
  } catch (err) {
236
378
  this.emit("warning", { layer: "controlPanel", message: err.message });
@@ -267,6 +409,7 @@ class GlassFrame extends EventEmitter {
267
409
  await this.logger.log(interaction.guild, {
268
410
  level: "info",
269
411
  title: "Protocol Layer Toggled",
412
+ layer: "controlPanel",
270
413
  description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
271
414
  dedupeKey: "panel:toggle"
272
415
  });
@@ -282,6 +425,24 @@ class GlassFrame extends EventEmitter {
282
425
  const payload = buildMetricsMessage(this, interaction.guild.id, scope);
283
426
  await interaction.update(payload);
284
427
  }
428
+
429
+ /**
430
+ * Page navigation on the engine dashboard. Gated by isOwner() on the
431
+ * clicking Discord identity - not by the password again, since a button
432
+ * click is already tied to a real, verified Discord session, and
433
+ * re-prompting for a password on every tab click would be unusable.
434
+ */
435
+ async _handleEngineButton(interaction) {
436
+ if (!this.isOwner(interaction.user.id)) {
437
+ await interaction.reply({ content: "This isn't for you.", ephemeral: true });
438
+ return;
439
+ }
440
+
441
+ const page = interaction.customId.replace("gfp_engine_", "");
442
+ const report = this.getEngineReport();
443
+ const payload = buildEnginePage(report, page);
444
+ await interaction.update(payload);
445
+ }
285
446
  }
286
447
 
287
448
  function mergeConfig(base, override) {