# GlassFrame Protocol — Full Documentation Context for AI Assistants

You are being given the complete current documentation for **GlassFrame
Protocol v2.1.0**, a layered, self-contained Discord.js security library
published on npm as `glassframe-protocol`. Everything below this line is
that documentation, consolidated from the project's official docs site
(https://glassframe-puce.vercel.app) into one file so it can be pasted into
any AI assistant's context in one go.

**How to use this document:** treat it as ground truth for how
GlassFrame Protocol v2.1.0 actually behaves - not as marketing copy. It
includes architecture, the full API surface, every configuration option,
the command/control-panel reference, and the version history including the
1.x-to-2.0.0 migration table (2.1.0 itself is additive - no breaking
changes on top of 2.0.0). If the person you're helping shows you code or a
`package.json` that implies a different installed version, prefer what
they show you over what's written here, and flag the mismatch rather than
assuming this document is current for their setup.

**What you should be ready to help with, using this context:** integrating
GlassFrame into an existing discord.js bot, debugging unexpected behavior
against the documented per-layer/per-guild logic, tuning thresholds in
`config.js`, extending the library with a custom layer or punishment action
via `registerLayer`/`registerAction` (v2.1.0+), setting up the owner-only
`!gf engine` dashboard, and upgrading a 1.x integration to 2.0.0+ using the
migration table in the Changelog section.

**A structural note worth knowing going in:** as of v2.0.0, per-guild
scoping is correct for layer on/off state and the whitelist (each server a
bot instance serves is independent, and state persists across restarts if
a `stateStore` is configured); v2.1.0 adds a per-guild custom command
prefix to that same list. Two things remain intentionally shared
across every guild one bot process serves: the phishing link blocklist,
and `config` itself (one merged object per process, not per guild). This
is covered in more depth in the "State" and "Protocol Layers" sections
below - it's called out here because it's the one piece of scope-related
nuance most likely to surprise someone new to the library.

Generated 2026-08-07 from the live documentation site, page by page, in the
order shown in the site's own navigation.

---


<!-- ============================================================ -->
<!-- SOURCE: GETTING_STARTED.md  (site page: index.html) -->
<!-- ============================================================ -->

# Getting Started

This is a library, not a bot - it has no login token of its own and does
nothing until you attach it to a discord.js `Client` you already have
running. This guide is written for that: you already have a working bot
and want to add GlassFrame Protocol to it.

If you're starting completely from scratch instead, `examples/basic-usage.js`
is a full minimal bot (client creation included) you can copy and run
directly.

## Prerequisites

- An existing discord.js v14.16+ bot, already logging in successfully
- Node.js 18+ and npm - see Step 1 below if you don't have these yet
  (GlassFrame's optional AI layer uses the global `fetch`, built into
  Node 18+)
- Your bot invited with these permissions: Manage Roles, Kick Members, Ban
  Members, Manage Channels, Manage Webhooks, View Audit Log, Moderate
  Members, Manage Messages
- These gateway intents enabled: `Guilds`, `GuildMembers`, `GuildMessages`,
  `MessageContent`, `GuildModeration`

## Step 1 - Install Node.js and npm

npm ships bundled with Node.js, so installing Node.js gets you both. If
`node -v` and `npm -v` already print version numbers, skip straight to
Step 2.

**Termux (Android)**

```bash
pkg update && pkg install nodejs
```

Use `nodejs-lts` instead of `nodejs` if you'd rather track the LTS release
line.

**Windows / macOS / Linux**

Install the LTS release from [nodejs.org](https://nodejs.org) - npm is bundled in. Linux users
can use their distro's package manager instead (`apt install nodejs npm` on
Debian/Ubuntu, etc.), or a version manager like `nvm` if you need more than
one Node version installed side by side.

**Verify**

```bash
node -v
npm -v
```

Node should report 18 or higher.

## Step 2 - Install the package

From your existing bot project (the same folder as its `package.json`):

```bash
npm install glassframe-protocol
```

`discord.js` is a listed dependency and installs automatically alongside
it. If your project doesn't already have its own copy, install it
explicitly too:

```bash
npm install glassframe-protocol discord.js
```

## Step 3 - Attach it to your client

In your main bot file, once your client is ready:

```js
const GlassFrame = require("glassframe-protocol");

client.once("ready", () => {
  const frame = new GlassFrame(client, {
    // Required: tell GlassFrame where to send its logs.
    getLogChannel: async (guild) =>
      guild.channels.cache.find((c) => c.name === "security-logs") ?? null,

    // Optional: which layers turn on immediately vs. stay off until
    // someone arms them from the panel. Layers not listed here start OFF.
    autoStart: ["basicSecurity", "antiRaid", "antiNuke"]
  });

  frame.on("warning", (w) => console.warn(`[GlassFrame:${w.layer}]`, w.message));
});
```

This is the entire integration. You do not write a spam filter, a raid
detector, a command handler, or any punishment logic - constructing
`GlassFrame` attaches everything it needs to your existing `client`
internally. Nothing else in your bot's code has to change.

## Step 4 - Make a log channel

Create a text channel (matching whatever `getLogChannel` looks for - the
example above uses one named `security-logs`) and make sure your bot can
see and send messages in it. Every alert, every punishment, every layer
toggle gets reported there through Components V2 messages.

## Step 5 - Turn features on

Two ways, pick either or both:

- **Code**: list layer names in `autoStart` (see Step 3) - applies to
  every server by default - or call
  `frame.enableLayer("antiNuke", guildId)` / `frame.disableLayer("antiNuke", guildId)`
  for one specific server anywhere after construction.
- **In Discord**: an admin (Manage Server permission) sends `!gf panel` and
  gets 5 buttons - Activate/Deactivate AntiRaid, AI Moderation, AntiNuke,
  Basic Security, and Full Protocol (all four at once) - scoped to the
  server the command was sent in. This is the only interface most server
  admins will ever need - no code, no slash commands.

Layer on/off state is per-guild - arming AntiNuke in one server has no
effect on any other server this bot is in - and it persists across
restarts if you pass a `stateStore` (a plain JSON file works out of the
box, no database needed). See docs/STATE.md for the full model.

## Step 6 (optional) - AI Moderation

Off by default. To turn it on:

1. Get one or more free API keys from [console.groq.com](https://console.groq.com)
2. Pass them in config:
   `const frame = new GlassFrame(client, {
  getLogChannel: /* ... */,
  config: {
    aiModeration: { apiKeys: (process.env.GROQ_API_KEYS || "").split(",") }
  }
});
`
3. Arm the layer (panel button, or add `"aiModeration"` to `autoStart`)

Multiple keys are rotated automatically, and any key that gets rate-limited
is benched temporarily rather than breaking moderation. It only calls out
to Groq for messages local detection scores as ambiguous - most messages
never leave the process.

## Step 7 (optional) - Your own bot-wide dashboard

Everything so far is per-server. If you're the one actually running the
bot and want a cross-server view instead - busiest servers, bot-wide AI
usage, a live activity feed - that's a separate thing:

1. Add your own Discord user ID to `options.owners`
2. Optionally set `config.engine.password` as a second factor
3. In a private channel (or DM the bot): `!gf engine`, adding the password
  after it if you set one

This is gated completely differently from everything else on this page -
by your owner ID, not Manage Server permission - and it's deliberately left
out of `!gf help` so a server admin wouldn't stumble onto it. Full
reference: docs/ENGINE.md.

## Everyday use, once it's running

Nobody needs this guide again after setup. Server admins use:

| Command | Does |
|---|---|
| `!gf panel` | The 5-button control panel |
| `!gf status` | Which layers are active |
| `!gf scan` | Checks current roles for name/permission mismatches |
| `!gf metrics` | Performance and cache health |
| `!gf phishing add/remove/list <domain>` | Manage the link blocklist |
| `!gf whitelist add/remove <userId>` | Exempt a user from punitive action in this server |
| `!gf prefix set/reset` | Change (or reset) this server's own command prefix |
| `!gf help` | Lists all of the above |

## Going deeper

- `README.md` - full feature overview
- `docs/PROTOCOL_LAYERS.md` - how a signal becomes an action
- `docs/STATE.md` - per-server layer/whitelist state and persistence
- `docs/ENGINE.md` - the owner-only bot-wide dashboard from Step 7
- `docs/CACHE_ARCHITECTURE.md` - every internal cache and its TTL
- `docs/PERFORMANCE.md` - the bounded-concurrency queues and `!gf metrics`
- `docs/COMMANDS.md` - full command/button reference
- `CHANGELOG.md` - version history
- `config.js` - every tunable threshold, all in one file


<!-- ============================================================ -->
<!-- SOURCE: README.md  (site page: overview.html) -->
<!-- ============================================================ -->

# GlassFrame Protocol

A layered, self-contained security engine for discord.js v14 bots. Four
independently-toggleable layers - AntiRaid, AntiNuke, Basic Security, and
Groq-powered AI Moderation - share one threat-scoring and punishment
pipeline instead of each reacting on its own, so the same member never gets
hit by four different modules for one incident, and a single soft flag
never turns into an auto-ban.

Everything is per-server: one bot process can run GlassFrame across many
Discord servers, each with its own independent set of active layers and its
own whitelist, persisted across restarts. See docs/STATE.md.

No slash commands - prefix commands only. No legacy `EmbedBuilder` - every
log message renders through Discord's Components V2 system. No emoji
anywhere in code or output.

## Install

GlassFrame Protocol is published on npm. From your bot project:

```bash
npm install glassframe-protocol
```

`discord.js` is a listed dependency and installs automatically alongside
it. If your project doesn't already have its own copy, install it
explicitly too:

```bash
npm install glassframe-protocol discord.js
```

Requires `discord.js` v14.16+ (Components V2 support and the
`guildAuditLogEntryCreate` event) and Node.js 18+ (global `fetch`, used only
by the optional AI layer). New to Node/npm? See Getting Started, Step 1.

## Required intents

```js
const { Client, GatewayIntentBits } = require("discord.js");

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
    GatewayIntentBits.GuildModeration
  ]
});
```

## Required bot permissions

Manage Roles, Kick Members, Ban Members, Manage Channels, Manage Webhooks,
View Audit Log, Moderate Members (timeout), Manage Messages. GlassFrame's
own role must sit above any role/member it needs to act on -
`RoleAnalyzer` checks this before every action instead of attempting and
failing.

## Usage

```js
const GlassFrame = require("glassframe-protocol");

client.once("ready", () => {
  const frame = new GlassFrame(client, {
    getLogChannel: async (guild) =>
      guild.channels.cache.find((c) => c.name === "security-logs") ?? null,
    whitelist: ["123456789012345678"], // trusted everywhere, seeded into every guild on first run
    autoStart: ["basicSecurity"], // starting layers for every guild - not a global on/off
    config: {
      antiRaid: { joinThreshold: 8 },
      aiModeration: { apiKeys: [process.env.GROQ_KEY_1, process.env.GROQ_KEY_2].filter(Boolean) }
    }
  });

  frame.on("warning", (w) => console.warn(`[GlassFrame:${w.layer}]`, w.message));
});

client.login(process.env.BOT_TOKEN);
```

Send `!gf panel` in any channel (Manage Server permission required) to get
the 5-button control panel and arm layers from there instead of in code.

## The four layers

- **Basic Security** - spam rate, duplicate flood, mention spam, a
  dedicated trust-aware @everyone/@here guard, local NLP scam/phishing/
  raid-recruitment scoring (including a specific invite-link +
  raid-language combo check), and link checks via `PhishingDatabase` (a
  server-editable blocklist plus raw-IP/punycode/brand-look-alike/shortener
  heuristics - `!gf phishing add/remove/list`).
- **AntiRaid** - join-velocity lockdown with automatic verification-level
  raise/restore, a composite alt-account score (account age, username
  entropy, missing avatar), and creation-time cluster correlation that
  catches a raid trickling in too slowly to trip the rate counter.
- **AntiNuke** - audit-log burst detection across channels, roles, bans,
  kicks, webhooks, emoji, and stickers per executor; a dangerous
  permission-grant watchdog covering both a role's own permissions
  changing *and* a member being quietly handed an already-dangerous role
  (with optional auto-revert); a channel permission-overwrite watchdog
  (catches @everyone getting locked out of, or let into, a channel without
  anything being deleted); a server-identity watchdog (name/icon/vanity URL
  changes); an invite-abuse watchdog (unrestricted invites from non-staff);
  and webhook-flood containment (deletes an abusive webhook and traces it
  back to its creator, since webhook messages carry no guild member for
  other layers to see).
- **AI Moderation** (optional, off by default) - Groq-powered second opinion
  for messages the local NLP scores as "gray zone": not clean, not clearly
  over threshold. Supports a pool of API keys with automatic per-key
  cooldown on rate limits. See `config.aiModeration` and
  `docs/CACHE_ARCHITECTURE.md` for how verdicts get cached and keys rotated.

Every layer starts disabled, per guild. Nothing runs until you list it in
`autoStart`, call `frame.enableLayer(name, guildId)`, or arm it from the
control panel. Add your own layer alongside these four with
`frame.registerLayer()` - see Extending it below.

## Performance under load

Every Discord mutation `PunishmentEngine` performs and every outbound Groq
request go through a bounded-concurrency queue (`frame.actionQueue` /
`frame.aiQueue`) instead of firing unbounded, so a burst can't trip a rate
limit by hammering it all at once. `!gf metrics` shows this server's numbers
by default (open cases, flagged members, active layers), with a button on
the message to switch to bot-wide totals across every server
(`frame.getMetrics()` / `frame.getGuildMetrics(guildId)`). Full write-up in
`docs/PERFORMANCE.md`.

## How punishment works

Layers never ban/kick/timeout directly - they report a signal
(`{ layer, weight, reason }`) to a shared `ThreatEngine`, which keeps one
decaying risk score per member. `PunishmentEngine` turns that score into an
action, but only after `RoleAnalyzer` confirms the bot can actually act on
the member and checks their real trust level (a genuine admin is flagged
for human review, never auto-banned), and only if there isn't already an
open case for that member from a moment ago. Full write-up in
`docs/PROTOCOL_LAYERS.md`.

## Extending it

- **`frame.registerLayer(name, layerInstance)`** - add your own security
  layer to the same pipeline the built-in four use (per-guild enable/state/
  persistence come free from extending `core/Layer`). Doesn't get a button
  on the panel automatically - the panel stays fixed at exactly 5.
- **`frame.registerAction(name, handler)`** - add a custom punishment
  action beyond ban/kick/timeout/quarantine, referenceable from
  `config.punishment.ladder`.
- **`!gf prefix set <newPrefix>`** - each server can run its own prefix;
  `config.prefix` always still works everywhere as a fallback.
- **`getLogChannel(guild, layerName)`** - route different layers to
  different channels, if you want; the second parameter is optional and
  backward compatible.
- **`!gf engine`** - an owner-only, bot-wide dashboard (busiest server, AI
  usage, live cross-server activity feed), gated separately from every
  other command and not listed in `!gf help`. See `docs/ENGINE.md`.

## Docs

- `GETTING_STARTED.md` - adding GlassFrame to a bot you already have running.
- `docs/PROTOCOL_LAYERS.md` - full architecture: containment vs. punishment,
  how a signal becomes an action, `registerLayer`/`registerAction`.
- `docs/STATE.md` - per-guild layer/whitelist state and how persistence
  across restarts works.
- `docs/CACHE_ARCHITECTURE.md` - every cache in the library, its key shape,
  its TTL, and how to turn on cache-level debug logging.
- `docs/PERFORMANCE.md` - the bounded-concurrency queues and internal
  metrics collector, and what `!gf metrics` shows.
- `docs/ENGINE.md` - the owner-only bot-wide dashboard: the password/lockout
  system and what each of its 5 pages shows.
- `docs/COMMANDS.md` - the full prefix command reference and the control
  panel's five buttons.
- `CHANGELOG.md` - version history.

## Tuning

Every threshold lives in `config.js`. Start with the defaults, watch the
security log channel for a week, and adjust - a very active server needs
higher spam/mention thresholds than a quiet one, though `config` is one
merged object for the whole bot process rather than settable per guild.
`frame.threatEngine` and `frame.punishmentEngine` are both plain properties
on the returned instance if you want to inspect scores or open cases at
runtime - both are also `EventEmitter`s (`signal` and `case`
respectively), alongside `frame`'s own `warning`, `layerToggled`, and
`layerRegistered` events. See Events and Scope in Protocol Layers for the full
picture - layer on/off, the whitelist, and (as of v2.1.0) the per-server
command prefix are correctly per-guild; the phishing blocklist and
`config` itself are the two things still shared across every guild.


<!-- ============================================================ -->
<!-- SOURCE: docs/PROTOCOL_LAYERS.md (+ Events/Scope, verified against source)  (site page: protocol-layers.html) -->
<!-- ============================================================ -->

# Protocol Layers

GlassFrame Protocol splits every reaction into two kinds, and keeping them
separate is the core design decision of the whole library.

## Containment vs. punishment

**Containment** is a guild-wide, reversible, time-critical measure a layer
takes on its own the moment it detects trouble - raising verification level
during a join spike, reverting a role that just gained Administrator. These
can't wait for a shared decision pipeline; a raid in progress needs an
answer in milliseconds, not after a round trip through scoring.

**Punishment** is anything aimed at one member - timeout, kick, ban,
quarantine. Every layer funnels this through the same two pieces instead of
deciding on its own:

1. **`ThreatEngine`** - turns a signal (`{ layer, weight, reason }`) into a
   decaying score per member, and a tier (`none/low/medium/high/critical`).
2. **`PunishmentEngine`** - turns a tier into an action, after checking
   `RoleAnalyzer` for hierarchy and trust, and checking for an already-open
   case on that member so a second signal a moment later merges into the
   first incident instead of firing again.

This is what makes the protocol "smart" rather than reflexive: a moderator
account that trips one soft signal doesn't get banned, a member the bot has
no hierarchy over doesn't get a failed API call retried on every message,
and twenty raid joins in ten seconds produce one aggregated log line
instead of twenty separate ones.

## The four layers

| Layer | Listens to | Containment it can do alone | Signals it reports |
|---|---|---|---|
| `basicSecurity` | `messageCreate` | deletes the offending message | spam rate, duplicate flood, mention spam, an @everyone/@here guard, scam/phishing/raid-recruitment language (incl. an invite-link + raid-language combo check), suspicious links (via `PhishingDatabase`) |
| `antiRaid` | `guildMemberAdd` | raises verification level, ends lockdown automatically | fast join-rate spikes, alt-account scoring, and creation-time cluster correlation (see below) |
| `antiNuke` | `guildAuditLogEntryCreate`, plus `messageCreate` only when webhook-abuse watching is on | reverts a dangerous permission grant, deletes an abusive webhook | destructive-action bursts per executor across channels/roles/bans/kicks/webhooks/emoji/stickers, plus two alert-only watchdogs*: a member handed an already-dangerous role, and @everyone's channel permissions overwritten |
| `aiModeration` | `messageCreate` (independently of `basicSecurity`) | - | AI-confirmed category + confidence for gray-zone messages |

* Those two watchdogs post straight to the log channel
(`logger.log({ level: "alert", ... })`) rather than calling
`punishmentEngine.report()` - unlike everything else in this table, they
never touch a member's `ThreatEngine` score and can't trigger an automatic
action on their own. Same pattern as the existing vanity/identity-change
watchdog: flag for a human, don't act.

Each layer extends `src/core/Layer.js`. Its event listeners attach
exactly once, ever (`attach()`), rather than on every enable/disable -
Discord's gateway has no concept of a listener scoped to one guild, so the
listener always fires and each handler's first real check is
`if (!this.isEnabled(guildId)) return;`. Enabling or disabling a layer is
per-guild: `layer.enable(guildId)` / `layer.disable(guildId)` track a
`Set<guildId>` (`enabledGuilds`), not a single boolean - see
docs/STATE.md for the full per-guild state and persistence model.

`basicSecurity` and `aiModeration` both listen to `messageCreate`
independently rather than one calling into the other - either can be
enabled without the other, and if both happen to flag the same message,
`PunishmentEngine`'s per-member case debounce (see above) merges the two
signals into one incident rather than double-punishing.

## Evasion-resistant detections

Two additions specifically target attacks designed to slip past the
straightforward rate-counters above:

- **Join clustering** (`AntiRaidLayer._clusterScore`) - a bulk-registered
  account farm can trickle members in slowly enough that the join-rate
  counter never trips. Clustering instead asks whether several recent
  joiners' accounts were all *created* within the same narrow window
  (`antiRaid.cluster`), which doesn't depend on how fast they join at all.
- **Webhook message flooding** (`AntiNukeLayer._handleWebhookMessage`) - a
  webhook message carries no guild member, so a "create a webhook, then
  spam through it" nuke is invisible to every per-member spam check in the
  library. AntiNuke remembers who created a webhook (`recentWebhooks`) for
  a short window and, if that webhook then floods messages
  (`antiNuke.webhookAbuse`), deletes the webhook and reports the original
  creator to `PunishmentEngine` - the only path in the library that reaches
  a member through something *other* than a direct per-member signal.

Both feed the same `ThreatEngine` / `PunishmentEngine` pipeline as
everything else; they're evasion-resistant in what they watch for, not in
how they respond.

## Why AntiNuke also runs a role audit

`RoleAnalyzer.scanRoles()` (role name vs. permission mismatch detection)
runs automatically once for a server the moment AntiNuke is armed *for
that server*, and again on demand via `!gf scan`. It reports flags, never
actions - a human decides what, if anything, to do about a role that looks
like impersonation bait or quietly holds dangerous permissions.

## Adding a layer without editing the library

`frame.registerLayer(name, layerInstance)` is the supported way to do this
now - no need to edit `GlassFrame.js`'s `this.layers` map by hand.
`layerInstance` must extend `core/Layer`: implement `attach()` (register
your event listener(s) once - it's called exactly once, at registration),
gate all real work behind `this.isEnabled(guildId)`, and report signals via
`this.frame.punishmentEngine.report({ guild, member, layer, weight, reason })`.
Per-guild enable/disable, state persistence, and `!gf status`/`!gf metrics`
all pick it up automatically, since they iterate `frame.layers` rather than
a fixed list. It does **not** get a button on the 5-button panel
automatically (that stays fixed at exactly 5) - toggle it with
`frame.enableLayer(name, guildId)` in code, or build your own command.

```js
const Layer = require("glassframe-protocol/dist/src/core/Layer");

class LinkAgeLayer extends Layer {
  constructor(frame) { super("linkAge", frame); }
  attach() {
    this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
  }
  async _handle(message) {
    if (!message.guild || message.author.bot) return;
    if (!this.isEnabled(message.guild.id)) return;
    // ... your detection logic, then:
    // await this.frame.punishmentEngine.report({ guild: message.guild, member: message.member, layer: "linkAge", weight: 40, reason: "..." });
  }
}

frame.registerLayer("linkAge", new LinkAgeLayer(frame));
```

## Custom punishment actions

`frame.registerAction(name, handler)` adds an action beyond the built-in
ban/kick/timeout/quarantine, so `config.punishment.ladder` can reference it
by name. `handler` is `async (member, record) => {}` and runs through the
same bounded-concurrency action queue as the built-in ones.

```js
frame.registerAction("addMutedRole", async (member) => {
  const role = member.guild.roles.cache.find((r) => r.name === "Muted");
  if (role) await member.roles.add(role);
});
```

```js
config: { punishment: { ladder: { medium: "addMutedRole" } } }
```

**A note on the require path above:** only `dist/` ships to npm (see
`package.json`'s `files` field) - `glassframe-protocol/src/...` 404s after a
real `npm install`, even though it's what you'll see if you're reading
this from inside a cloned copy of the repo itself. `dist/src/...` mirrors
`src/`'s structure exactly, so the rest of the path stays the same.

## Events

`GlassFrame`, `ThreatEngine`, and `PunishmentEngine` are all
`EventEmitter`s. None of this is required - GlassFrame runs fine with zero
listeners attached - but each is a hook point for a custom dashboard, DM
alerts, or external logging without touching library code.

| Emitter | Event | Fires when |
|---|---|---|
| `frame` | `warning` | A layer or the control panel hits a non-fatal error - a failed Discord API call, a role the bot can't edit. Payload: `{ layer, message }`. |
| `frame` | `layerToggled` | Any layer is armed or disarmed, from code, the panel, or a command. Payload: `{ layer, guildId, enabled }`. |
| `frame.threatEngine` | `signal` | Any layer reports a signal, before `PunishmentEngine` decides what to do with it. Payload: `{ guildId, userId, layer, reason, weight, score, tier }`. |
| `frame.punishmentEngine` | `case` | After `PunishmentEngine` executes (or logs/alerts instead of executing) an action. Payload is the case record: `{ tier, score, trust, action, reasons, note?, error? }`. |
| `frame` | `layerRegistered` | A layer is added via `frame.registerLayer()`. Payload: `{ layer }`. |

```js
frame.punishmentEngine.on("case", (record) => {
  if (record.action === "ban") notifyModTeam(record);
});
```

## Scope: shared vs. per-guild

One `GlassFrame` instance can serve every guild the bot is in. As of
v2.0.0, everything that should be per-guild actually is - see
docs/STATE.md for the full model. Two things remain intentionally
shared across every guild the process serves, plus one deliberate exception
built for exactly that reason (see the note at the end of this section).

### Per-guild

- Which layers are enabled (`Layer.enabledGuilds`, a `Set<guildId>`) -
  `frame.enableLayer(name, guildId)` / `frame.disableLayer(name, guildId)`,
  the panel, and `!gf status` all operate on one guild only.
  `getStatus(guildId)` genuinely respects the guild it's passed.
- The whitelist (`Map<guildId, Set<userId>>`) - `frame.addToWhitelist(guildId, userId)`,
  `frame.removeFromWhitelist(guildId, userId)`, `frame.isWhitelisted(guildId, userId)`.
- The command prefix (v2.1.0+) - `frame.getPrefix(guildId)` /
  `frame.setPrefix(guildId, prefix)`, or `!gf prefix set/reset`.
  `config.prefix` is the fallback for any guild that hasn't set its own.
- Threat scores and signal history (`ThreatEngine`)
- Open cases (`PunishmentEngine`)
- Join-rate tracking and active lockdowns (`AntiRaidLayer`)
- Audit-log burst tracking and the webhook watch list (`AntiNukeLayer`)
- Basic Security's spam-rate buffer, now keyed by `guildId:userId` rather
  than `userId` alone - the same user active in two servers no longer
  shares one rate-limit window.

### Still shared across every guild

- The phishing blocklist (`!gf phishing add/remove/list`) - one list for
  the whole bot process, not per guild.
- `options.config` itself - one merged object for the whole process,
  not settable per guild.

If a server needs genuinely different thresholds rather than just a
different set of active layers, that's not built in yet - run one
`GlassFrame` instance per server for now.

**The one deliberate exception:** `!gf engine` is a bot-wide dashboard
by design, not an oversight - it exists specifically to show a bot owner
everything across every server at once (busiest servers, AI usage,
aggregate performance, a live cross-server activity feed). It's gated
entirely differently from everything else on this page: by `options.owners`
(a Discord user ID allowlist checked in code, nothing a server admin can
grant), not by Manage Server permission, and it's deliberately left out of
`!gf help`. See docs/ENGINE.md.

**State persistence works.** `options.stateStore` accepts a
`MemoryStateStore` (default, lost on restart) or `JSONFileStateStore` (a
single JSON file, restart-safe). As of v2.0.0 every layer toggle and
whitelist change - and as of v2.1.0, every prefix change too - is saved per
guild and restored automatically at startup and whenever the bot joins a
new guild. See docs/STATE.md for the full
model, including `frame.ready` and how to write a custom store.


<!-- ============================================================ -->
<!-- SOURCE: docs/STATE.md  (site page: state.html) -->
<!-- ============================================================ -->

# Per-Guild State and Persistence

One GlassFrame instance can serve many Discord servers at once. Three
things are tracked independently per server, and all three survive a bot
restart if you give GlassFrame a persistent `stateStore`:

- **Which layers are on.** Server A can run AntiRaid + AntiNuke while Server
  B runs nothing, or everything, independently. There is no such thing as
  "GlassFrame is on" globally - only "layer X is on for guild Y."
- **The whitelist.** Exempting a user in one server does not exempt them
  anywhere else.
- **The command prefix** (v2.1.0+), if a server has set its own via
  `!gf prefix set`. `config.prefix` is still the fallback everywhere a
  server hasn't customized it.

Everything else that's naturally guild-scoped already was before this -
threat scores, open cases, join-rate tracking, audit-log burst tracking are
all keyed by `guildId` internally. The two items above are the ones that
used to be accidentally global; see `CHANGELOG.md` for the 2.0.0 entry if
you're upgrading from an earlier version.

## How it's stored

`Layer.enabledGuilds` is a `Set<guildId>` per layer (not one shared
boolean). `GlassFrame.whitelist` is a `Map<guildId, Set<userId>>`. Both are
read through the frame's own methods rather than touched directly:

```js
frame.enableLayer("antiRaid", guildId);
frame.disableLayer("antiRaid", guildId);
frame.getStatus(guildId); // { basicSecurity: true, antiRaid: false, ... }

frame.addToWhitelist(guildId, userId);
frame.removeFromWhitelist(guildId, userId);
frame.isWhitelisted(guildId, userId);
```

## Persistence across restarts

Pass a `stateStore` when constructing GlassFrame - `MemoryStateStore`
(default, lost on restart) or `JSONFileStateStore` (a single JSON file, no
database, ARM64/Termux-friendly):

```js
const { JSONFileStateStore } = require("glassframe-protocol");

const frame = new GlassFrame(client, {
  getLogChannel: /* ... */,
  stateStore: new JSONFileStateStore("./glassframe-state.json"),
  autoStart: ["basicSecurity", "antiRaid"]
});
```

Every time a layer is toggled or the whitelist changes for a guild,
GlassFrame writes that guild's full state (which layers are on, its
whitelist) to the store. At startup, for every guild the bot is already in,
and again automatically whenever the bot joins a new guild
(`guildCreate`), GlassFrame:

1. Checks the store for saved state for that guild.
2. If found, restores exactly that - the layers that were on stay on.
3. If nothing is saved yet (a guild GlassFrame has never seen before),
  applies `autoStart` and the constructor's `whitelist` option (see below)
  as that guild's starting defaults.

Restoring is async (a real `stateStore` might read from disk or a
database), so it can't finish before the constructor returns. In practice
this resolves well before a Discord event could possibly arrive, so most
code never needs to think about it - but if you want a guarantee, `await
frame.ready` after construction; it resolves once every guild the bot was
already in has been restored (guilds joined later via `guildCreate`
restore independently and aren't part of this promise, since there's
nothing they could race against).

`autoStart` and the `whitelist` option are only ever *defaults for a
guild's first run* - once a guild has any saved state (even "everything
off"), neither applies to it again; the saved state is the source of truth
from then on.

## The whitelist constructor option

```js
new GlassFrame(client, { getLogChannel: /* ... */, whitelist: ["123456789012345678"] });
```

This seeds a starting whitelist for every guild - both the ones already in
`client.guilds.cache` and any the bot joins later - the first time
GlassFrame sees that guild (i.e., it never overrides a guild's own saved
whitelist). Think of it as "these users are trusted everywhere by default";
`frame.removeFromWhitelist(guildId, userId)` still works normally
per-guild afterward.

## Writing your own store

Implement two async methods and pass an instance as `stateStore`:

```js
class MyStore {
  async get(guildId) { /* return the saved state object, or null */ }
  async set(guildId, state) { /* persist `state` for this guildId */ }
}
```

`state` is `{ layers: { basicSecurity: true, antiRaid: false, ... }, whitelist: ["userId1", "userId2"], prefix: "!custom " }` - `prefix` is `null` for a server using the default.


<!-- ============================================================ -->
<!-- SOURCE: docs/ENGINE.md  (site page: engine.html) -->
<!-- ============================================================ -->

# The Engine Dashboard

`!gf engine` is a bot-wide, cross-server dashboard for whoever actually
runs the bot - not a per-server admin tool. It shows things a single
server's admin shouldn't be able to see about *other* servers (which
server is busiest, bot-wide AI usage, a live feed of what's happening
everywhere), so it has its own, separate permission model from every other
command.

It is deliberately **not** listed in `!gf help` or `docs/COMMANDS.md`'s
in-Discord command table - a random server admin shouldn't even know it
exists. You're expected to learn about it from this page.

## Who can use it

Two independent checks, not one:

1. **`isOwner(userId)`** - you must be listed in `options.owners` when you
  construct `GlassFrame`. This is required, full stop, for the command
  itself *and* every page-navigation button click afterward.
2. **A password** (optional) - if `config.engine.password` is set, the
  initial `!gf engine <password>` command also needs the correct password.
  Page-navigation button clicks after that don't re-prompt for it, since a
  button click is already tied to a real, verified Discord identity - only
  the text-command entry point needs the second factor.

```js
const frame = new GlassFrame(client, {
  getLogChannel: /* ... */,
  owners: ["your-discord-user-id"],
  config: {
    engine: {
      password: process.env.GLASSFRAME_ENGINE_PASSWORD // never hardcode a real one
    }
  }
});
```

Leave `config.engine.password` unset to skip the second factor and rely on
the owners list alone.

## What happens to the password

Typing a password into a normal Discord message means it briefly sits in
plain text in a channel, visible to anyone watching and to Discord's own
message history. GlassFrame does two things about that:

- The command message is **deleted immediately** after being checked,
  whether the password was right or wrong.
- Wrong guesses count toward a **lockout** - `config.engine.maxAttempts`
  wrong attempts (default 3) locks that user out for
  `config.engine.lockoutMs` (default 10 minutes), making brute-forcing
  impractical.

Use a private channel (or a channel only you can see) for this command
regardless - deletion happens right after Discord delivers the message, not
before anyone in the channel could have glimpsed it.

## The five pages

One row of tab buttons switches between them; the active tab is
highlighted.

| Page | Shows |
|---|---|
| **Overview** | Version, uptime, guild count, layer adoption across every server |
| **Servers** | The busiest servers ranked by event volume since this process started |
| **AI** | Groq key pool status, cooldowns, call volume, verdict cache hit rate |
| **Performance** | Action/AI queue depth, all three cache stats, average latency per operation |
| **Activity Log** | The last several things logged anywhere, any server, newest first |

All of it comes from one call: `frame.getEngineReport()`, if you want the
raw data instead of the rendered message (for your own dashboard, an API
endpoint, whatever).

## "Busiest" and the activity log, precisely

- **Busiest** is a count of events (messages/joins/audit entries) any armed
  layer processed for that server, tracked in memory since the process
  started - it resets on restart and isn't a judgment about which server is
  causing trouble, just which one has the most traffic.
- The **activity log** is the last 30 events logged anywhere (any server,
  any layer), kept in memory by `SmartLogger` - see
  `docs/CACHE_ARCHITECTURE.md`. The dashboard shows the most recent 10 of
  those.


<!-- ============================================================ -->
<!-- SOURCE: docs/CACHE_ARCHITECTURE.md  (site page: cache-architecture.html) -->
<!-- ============================================================ -->

# Cache Architecture

GlassFrame Protocol keeps all of its state in memory via one shared
primitive, `ProtocolCache` (`src/core/Cache.js`), rather than scattering
plain `Map`s through every layer. Every cache instance below is a
`ProtocolCache`, and every one of them can be put into debug mode so you can
watch it operate at runtime - see "Turning on cache logging" below.

## Why a shared cache primitive

- One place to reason about TTL, eviction, and stats instead of five
  slightly-different `Map` patterns spread across layers.
- Optional debug logging is a constructor flag, not something bolted on
  per-module after the fact.
- `getStats()` returns hits/misses/sets/evictions/size for any cache at
  runtime, which is useful when you're tuning thresholds in `config.js` and
  want to know whether a value is actually being reused.
- `countByPrefix(prefix)` counts non-expired entries whose key starts
  with a given prefix, without listing them. `frame.getGuildMetrics(guildId)`
  uses this to count a single server's flagged members and open cases out
  of the shared threat-score and case caches, which are keyed
  `guildId:userId`.

## Caches in use

| Cache | Owner | Key shape | TTL | Purpose |
|---|---|---|---|---|
| `threat-scores` | `ThreatEngine` | `guildId:userId` | none (decay-based; pruned once decayed near zero) | The running, decaying risk score every layer contributes signals to. |
| `open-cases` | `PunishmentEngine` | `guildId:userId` | `punishment.caseCooldownMs` (default 30s) | Prevents re-punishing or re-logging the same member while an incident is still fresh. Expiry length **is** the debounce window. |
| `nuke-snapshots` | `AntiNukeLayer` | `guildId` | `antiNuke.snapshotTtlMs` (default 1h) | A lightweight forensic snapshot of role/channel names and positions, captured the instant a threshold trips, kept only for reference. |
| `recent-webhooks` | `AntiNukeLayer` | webhook ID | `antiNuke.webhookAbuse.watchPeriodMs` (default 5m) | Maps a freshly created webhook back to whoever created it, so a message flood through that webhook (which carries no guild member of its own) can still be traced to an executor. |
| `groq-verdicts` | `GroqClient` | hash of the first 500 characters of the message | `aiModeration.cacheTtlMs` (default 5m) | Stops a burst of identical/near-identical messages from each costing a fresh Groq call. |

Two more `Map`s exist outside `ProtocolCache` because they're pure sliding
windows with no need for hit/miss stats: `AntiRaidLayer.joinTimestamps` /
`AntiRaidLayer.joinProfiles` (cluster correlation) and
`AntiNukeLayer.actionLog` / `AntiNukeLayer.webhookMessageCounts`. All four
are swept lazily - old timestamps are filtered out on the next write rather
than on a timer, since they're only ever read right after a write.

`SmartLogger.recentLogs` is a fixed-size array (last 30, newest first, not
a `ProtocolCache`) recording every log event across every guild - it's what
powers the owner-only `!gf engine` dashboard's Activity Log page. See
`docs/ENGINE.md`.

Bounded-concurrency queues (`frame.actionQueue`, `frame.aiQueue`) and the
internal metrics collector (`frame.performance`) are a separate concern from
caching - see `docs/PERFORMANCE.md`.

## Turning on cache logging

Pass `debug: true` when constructing GlassFrame:

```js
const frame = new GlassFrame(client, {
  getLogChannel: (guild) => guild.channels.cache.find((c) => c.name === "security-logs"),
  debug: true
});
```

(Equivalently, set `cache: { debug: true }` in `config.js` - the
constructor option takes priority if both are set.)

With debug mode on, every cache above prints a line like:

```js
[GlassFrame:Cache:open-cases] SET 123456789012345678:987654321098765432
[GlassFrame:Cache:groq-verdicts] HIT h482910335
[GlassFrame:Cache:threat-scores] EXPIRE 123456789012345678:555555555555555555
```

`SmartLogger` has its own separate debug channel for the same flag
(`[GlassFrame:SmartLogger] merge ...` / `final edit ...`), covering the
event-aggregation behavior described in `docs/PROTOCOL_LAYERS.md`.

This is meant for local debugging while you tune `config.js` - leave it off
in production, since it writes to `console.log` on every cache operation and
will get noisy fast on an active server.

## Memory growth

`threat-scores` is the only cache with no hard TTL, since a member's score
needs to persist and decay rather than vanish at a fixed time.
`ThreatEngine` prunes it every 10 minutes, removing any entry whose decayed
score has fallen under 0.5 **and** hasn't been touched in at least four
half-lives - by then it's noise, not signal. Every other cache either has a
real TTL or is a self-trimming sliding window, so none of them grow
unbounded over a long-running process.


<!-- ============================================================ -->
<!-- SOURCE: docs/PERFORMANCE.md  (site page: performance.html) -->
<!-- ============================================================ -->

# Performance

GlassFrame Protocol is built to stay fast under exactly the conditions that
make speed hardest: a raid, a nuke attempt, or a wave of borderline messages
all create a burst of work at the same moment. Two small pieces exist
specifically for that.

## EventQueue - bounded concurrency

`src/core/EventQueue.js` is a plain async queue with a concurrency cap.
Every actual Discord mutation `PunishmentEngine` performs (ban/kick/timeout/
role removal) and every outbound Groq request `GroqClient` makes goes
through one of two queues instead of firing directly:

- `frame.actionQueue` - Discord mutations, default concurrency 4
  (`config.performance.actionConcurrency`).
- `frame.aiQueue` - Groq requests, default concurrency 3
  (`config.performance.aiConcurrency`).

Nothing is ever dropped - tasks queue instead of running unbounded. This
matters most during a burst: twenty raid removals firing at once is exactly
what trips Discord's per-route rate limit and makes every request slower,
including the ones that matter. Capping concurrency keeps steady throughput
instead of a stall-then-flood pattern. `GroqClient`'s per-request timeout
clock only starts once a request actually leaves the queue, so a busy
moment never produces a false "the AI didn't respond in time" the way it
would if the timer started at the moment of the API call being requested.

Both queues expose `getStats()` (`active`, `pending`, `completed`, `failed`,
`concurrency`), surfaced through `frame.getMetrics()` and `!gf metrics`.

## PerformanceMonitor - lightweight internal metrics

`src/core/PerformanceMonitor.js` tracks three things, all in memory,
neither requiring a dependency:

- **Event counts** - `recordEvent(name, guildId)` increments a counter.
  Every layer's main handler calls this once per event it processes
  (`basicSecurity.messageCreate`, `antiRaid.guildMemberAdd`,
  `antiNuke.auditEntry.<type>`, `groqClient.classify`, ...). `guildId` is
  optional.
- **Per-guild activity** - when a handler passes `guildId` to
  `recordEvent`, that guild's tally increments too, separately from the
  by-name counters above. `topGuildsByActivity(limit)` returns guild IDs
  ranked busiest-first. Not part of `getMetrics()`'s own snapshot below -
  it feeds `frame.getEngineReport()` instead, powering the owner-only
  `!gf engine` dashboard's Servers page. See `docs/ENGINE.md`.
- **Latency samples** - `time(name)` returns a function; call it when the
  unit of work finishes. The last 50 samples per name are kept and averaged.
  Used around `punishmentEngine.execute` and `groqClient.classify`, the two
  places most likely to be slow (Discord API round trips, network calls).

`frame.getMetrics()` returns all of this plus every cache's stats in one
snapshot:

```js
{
  performance: { uptimeMs, events: { "basicSecurity.messageCreate": 412, ... }, avgLatencyMs: { "groqClient.classify": 340.2, ... } },
  queues: { actions: { active, pending, completed, failed, concurrency }, ai: { ... } },
  caches: { threatScores: { size, hits, misses, evictions }, openCases: { ... }, groqVerdicts: { ... } }
}
```

`!gf metrics` renders this same bot-wide snapshot as a Components V2
message when switched to the "Global" view. Neither `EventQueue` nor
`PerformanceMonitor` phones out anywhere - everything here stays in the
process.

`frame.getGuildMetrics(guildId)` returns a smaller, per-server shape
instead - `{ status, whitelistSize, flaggedMembers, openCases }` - using
`Cache.countByPrefix()` against the same shared threat-score and case
caches (keyed `guildId:userId`) rather than tracking a separate per-guild
counter. `!gf metrics` shows this per-server view by default, with a
button on the message to switch to the bot-wide snapshot above and back -
see Commands - The metrics button.


<!-- ============================================================ -->
<!-- SOURCE: docs/COMMANDS.md  (site page: commands.html) -->
<!-- ============================================================ -->

# Commands

GlassFrame Protocol registers **no slash commands**. Every command is a
prefix command (default prefix `!gf`, configurable via `config.prefix`),
requires Manage Server permission (or being the guild owner), and is
throttled per user per guild by `performance.commandCooldownMs` (default
3s) - a repeated or accidental double-send is silently ignored rather than
running twice or getting a "slow down" reply of its own.

| Command | Does |
|---|---|
| `!gf panel` | Sends the 5-button control panel (see below). |
| `!gf status` | Shows which of the four layers are currently active **in this server**. |
| `!gf scan` | Runs `RoleAnalyzer.scanRoles()` on demand and reports any role name/permission mismatches. |
| `!gf metrics` | Shows **this server's** metrics by default - a button on the message switches to bot-wide (all servers) and back. See `docs/PERFORMANCE.md`. |
| `!gf phishing add <domain>` | Adds a domain to the link blocklist. |
| `!gf phishing remove <domain>` | Removes a domain from the blocklist. |
| `!gf phishing list` | Lists every blocked domain. |
| `!gf whitelist add <userId>` | Exempts a user from punitive action **in this server**. |
| `!gf whitelist remove <userId>` | Removes that exemption for this server. |
| `!gf prefix set <newPrefix>` | Changes this server's own command prefix. |
| `!gf prefix reset` | Goes back to the default prefix. |
| `!gf help` | Lists these commands (this list, minus `engine` - see below). |

Everything above operates on the server the command was sent in - arming
AntiRaid, whitelisting someone, or checking status in one server has no
effect on any other server the bot is in. See docs/STATE.md.

## The control panel

`!gf panel` sends a message with exactly five buttons in one row, scoped to
the server it was sent in:

1. **Activate/Deactivate AntiRaid**
2. **Activate/Deactivate AI Moderation**
3. **Activate/Deactivate AntiNuke**
4. **Activate/Deactivate Basic Security**
5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once, for this server only

Every layer starts disabled in every server - nothing runs until it's
explicitly activated, either from the panel or via
`frame.enableLayer("antiRaid", guildId)` in code. Button labels flip
between Activate/Deactivate based on that server's current state, and the
panel message updates in place on every click rather than posting a new
message each time. Clicking a button also drops one aggregated log line in
the security log channel noting who changed what.

## The metrics button

`!gf metrics` (and the panel's numbers, indirectly) has one button of its
own, separate from the panel's five: **Show Global (All Servers)** /
**Show This Server**, flipping the message between this server's counts
(open cases, flagged members, active layers, whitelist size) and bot-wide
totals across every server (queue depth, cache stats, average latency).

**Still shared, not per-guild:** the phishing blocklist
(`!gf phishing`) is the one command surface left that applies to every
server this bot process is in, not just the one it was used in - see
Protocol Layers - Scope for the full breakdown of what's per-guild vs.
still shared.

## !gf engine

Deliberately not in the table above, and not listed by `!gf help`. Gated
by `options.owners` (a Discord user ID allowlist checked in code) rather
than Manage Server permission, and optionally a second-factor password
(`config.engine.password`) with attempt-limited lockout. Opens a 5-page,
bot-wide dashboard - busiest servers, AI usage, live cross-server activity,
aggregate performance. Full reference: docs/ENGINE.md.


<!-- ============================================================ -->
<!-- SOURCE: config.js, annotated  (site page: configuration.html) -->
<!-- ============================================================ -->

# Configuration

Every threshold in GlassFrame lives in this one file. Start with the defaults, watch the security log channel for a week, and adjust - a very active server needs higher spam/mention thresholds than a quiet one. One caveat: this whole file is one merged config object for the entire bot process, not settable per guild - see Protocol Layers - Scope if this bot serves more than one server.

```js
"use strict";

module.exports = {
  // Prefix for all commands. This library never registers slash commands.
  prefix: "!gf ",

  // Documentation only - the actual channel resolution always goes through
  // the required options.getLogChannel(guild) function you provide.
  logChannelName: "security-logs",

  roleAnalysis: {
    // Role-name fragments (lowercased, substring match) that imply real authority.
    protectedNames: [
      "owner", "admin", "administrator", "moderator", "mod",
      "staff", "discord staff", "security"
    ],
    // Permission names (PermissionsBitField.Flags keys) treated as "dangerous"
    // when granted to a role - watched by AntiNuke's grant watchdog.
    dangerousPermissions: [
      "Administrator", "ManageGuild", "ManageRoles", "ManageWebhooks",
      "BanMembers", "ManageChannels"
    ],
    // Permission names that mark a member as genuinely trusted staff.
    trustPermissions: ["Administrator", "ManageGuild", "ManageRoles", "BanMembers", "KickMembers"],
    // A role younger than this that already holds a dangerous permission is extra suspicious.
    newRoleGraceMs: 1000 * 60 * 10
  },

  threatEngine: {
    // Score halves every this-many ms of inactivity - stale flags fade instead of lingering forever.
    decayHalfLifeMs: 1000 * 60 * 30,
    tiers: { low: 20, medium: 45, high: 70, critical: 90 }
  },

  punishment: {
    // While a case is open for a member, new signals merge into it instead of re-punishing/re-logging.
    caseCooldownMs: 1000 * 30,
    ladder: { low: "log", medium: "timeout", high: "kick", critical: "ban" },
    timeoutDurationMs: 1000 * 60 * 10,
    // Action used instead of kick/ban when the target holds a real trusted/protected role.
    protectedTrustAction: "quarantine"
  },

  basicSecurity: {
    spam: { windowMs: 6000, messageThreshold: 6, duplicateThreshold: 4 },
    mentionSpam: { maxMentionsPerMessage: 8 },
    nlp: { scamScoreThreshold: 0.62, phishingScoreThreshold: 0.55, raidCalloutThreshold: 0.3 },
    // Seed domains for PhishingDatabase (src/security/PhishingDatabase.js).
    // Empty by default - populate with what you actually see via
    // `!gf phishing add <domain>` rather than shipping unverified blocklists.
    linkGuard: { seedDomains: [] }
  },

  antiRaid: {
    joinWindowMs: 10000,
    joinThreshold: 6,
    minAccountAgeMs: 1000 * 60 * 60 * 24 * 3,
    usernameEntropyFloor: 2.6,
    usernameEntropyCeiling: 4.6,
    lockdownDurationMs: 1000 * 60 * 10,
    altScoreThreshold: 55,
    // Behavioral correlation: catches a raid that trickles in slowly enough
    // to dodge the join-rate counter above, by noticing that several
    // "unrelated" new joiners were actually all created within the same
    // narrow window - a classic sign of a bulk-registered account farm.
    cluster: {
      windowMs: 1000 * 60 * 5,
      minClusterSize: 4,
      creationToleranceMs: 1000 * 60 * 2
    }
  },

  antiNuke: {
    windowMs: 15000,
    thresholds: {
      channelDelete: 3, channelCreate: 5, roleDelete: 3, roleCreate: 5,
      memberBanAdd: 4, memberKick: 5, webhookCreate: 3, botAdd: 1, guildUpdate: 3,
      emojiDelete: 5, emojiCreate: 8, stickerDelete: 3, stickerCreate: 5
    },
    watchDangerousGrants: true,
    autoRevertDangerousGrants: false,
    watchVanityChanges: true,
    // A brand-new invite with no expiry and no use limit, created by anyone
    // other than the owner, is worth a look the moment it's created.
    watchInviteAbuse: true,
    // Watches @everyone's channel permission overwrites specifically - a
    // channel getting locked out for everyone, or a private channel getting
    // opened to everyone, is a nuke technique that doesn't delete anything.
    watchOverwriteAbuse: true,
    // A webhook that starts pushing a burst of messages within minutes of
    // being created is a classic "nuke via webhook" pattern - this catches
    // it even though the messages themselves look like normal messageCreate
    // events. The offending webhook is deleted as immediate containment.
    webhookAbuse: {
      enabled: true,
      messageThreshold: 8,
      windowMs: 8000,
      // How long after creation a webhook is still considered "recent" enough to watch.
      watchPeriodMs: 1000 * 60 * 5
    },
    // Forensic role/channel snapshot retention, taken the moment a threshold trips.
    snapshotTtlMs: 1000 * 60 * 60
  },

  aiModeration: {
    // Empty by default - the layer stays idle until you supply at least one key.
    apiKeys: [],
    model: "llama-3.3-70b-versatile",
    endpoint: "https://api.groq.com/openai/v1/chat/completions",
    timeoutMs: 6000,
    cacheTtlMs: 1000 * 60 * 5,
    // A conservative self-imposed budget shared across the whole key pool, not Groq's own rate limit.
    maxCallsPerMinute: 20,
    // Messages whose local NLP score falls in this band get a second, AI-backed opinion.
    grayZone: { min: 0.30, max: 0.62 }
  },

  logging: {
    // Near-duplicate log events inside this window merge into one message with a running count.
    aggregationWindowMs: 15000,
    // Minimum time between sends to the same guild's log channel.
    minSendIntervalMs: 1200
  },

  // Bounded-concurrency controls (src/core/EventQueue.js) so a burst - a raid,
  // a nuke attempt, a wave of gray-zone messages - queues smoothly instead of
  // firing every Discord/Groq call at once and tripping a rate limit right
  // when speed matters most. See docs/PERFORMANCE.md.
  performance: {
    actionConcurrency: 4, // simultaneous ban/kick/timeout/quarantine calls
    aiConcurrency: 3, // simultaneous outbound Groq requests
    // Per-user, per-guild floor between prefix commands, so a doubled
    // keypress can't spawn two control panels or send a command twice.
    commandCooldownMs: 3000
  },

  cache: {
    // Default for GlassFrame's debug flag - logs every cache SET/HIT/MISS/EXPIRE to the console.
    // See docs/CACHE_ARCHITECTURE.md. Overridden by the `debug` constructor option if provided.
    debug: false
  },

  // The owner-only `!gf engine` dashboard - see docs/ENGINE.md. Being an
  // owner (options.owners) is required no matter what; a password is an
  // optional second factor on top of that for the initial command only -
  // page-navigation button clicks are still gated by isOwner() but don't
  // re-prompt for the password.
  engine: {
    // Set via an environment variable in your own bot, e.g.
    // process.env.GLASSFRAME_ENGINE_PASSWORD - never hardcode a real
    // password into a committed config.js. Leave null to skip the second
    // factor and rely on the owners list alone.
    password: null,
    maxAttempts: 3,
    lockoutMs: 1000 * 60 * 10
  }
};
```


<!-- ============================================================ -->
<!-- SOURCE: examples/basic-usage.js  (site page: basic-usage.html) -->
<!-- ============================================================ -->

# Basic Usage

This is a full minimal bot - client creation included - copied directly from the library's `examples/` folder. Use it if you're starting completely from scratch instead of attaching GlassFrame to a bot you already have running.

```js
"use strict";

const { Client, GatewayIntentBits } = require("discord.js");
const GlassFrame = require("glassframe-protocol");

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
    GatewayIntentBits.GuildModeration
  ]
});

client.once("ready", () => {
  const frame = new GlassFrame(client, {
    getLogChannel: async (guild) => {
      return guild.channels.cache.find((c) => c.name === "security-logs") ?? null;
    },
    whitelist: [],
    // Layers not listed here stay off until someone presses the panel button
    // or you call frame.enableLayer(name) yourself.
    autoStart: ["basicSecurity", "antiRaid", "antiNuke"],
    debug: false,
    config: {
      aiModeration: {
        // Supply one key or several - GlassFrame rotates across the pool and
        // benches any key that comes back rate-limited.
        apiKeys: (process.env.GROQ_API_KEYS || "").split(",").filter(Boolean)
      }
    }
  });

  frame.on("warning", (w) => console.warn(`[GlassFrame:${w.layer}]`, w.message));
  frame.punishmentEngine.on("case", (record) => {
    console.log(`[GlassFrame] ${record.action} on ${record.userId} (tier ${record.tier})`);
  });

  console.log(`GlassFrame Protocol ready in ${client.guilds.cache.size} guild(s).`);
  console.log('Send "!gf panel" in a channel (Manage Server permission) to open the control panel.');
});

client.login(process.env.BOT_TOKEN);
```


<!-- ============================================================ -->
<!-- SOURCE: CHANGELOG.md  (site page: changelog.html) -->
<!-- ============================================================ -->

# Changelog

All notable changes to GlassFrame Protocol are logged here. This library
follows semantic versioning (MAJOR.MINOR.PATCH). Runtime version metadata
lives in `src/core/VersionInfo.js` (`VersionInfo.info()` /
`VersionInfo.banner()`), so code can read the current version without
parsing this file.

## [2.1.0] - 2026-08-01

Additive - nothing from 2.0.0's API changes. New detections, a plugin
system, per-guild customization, and an owner-only cross-server dashboard.

### Added - security detections

- **Dangerous role grants to a member.** The existing permission-grant
  watchdog only caught a role's own permissions changing (`RoleUpdate`).
  It missed the other half: someone with `ManageRoles` handing an
  already-dangerous role directly to a member (`MemberRoleUpdate`) without
  ever touching the role's definition. Now watched, gated the same way -
  only flagged when the executor isn't recognized trusted staff.
- **Channel permission-overwrite abuse.** A nuke doesn't have to delete
  anything - locking @everyone out of a public channel, or opening a
  private one up to @everyone, does equivalent damage through permission
  overwrites. Only @everyone's own overwrite is watched; a role- or
  member-specific overwrite change is routine administration and ignored.
- **A dedicated @everyone/@here guard** in Basic Security, weighted well
  above regular mention spam (a single mass ping reaches every member at
  once) and skipped for recognized trusted staff, so a real admin's
  announcement isn't flagged.
- **Raid-recruitment language is now actually used.** `raidCallout` scoring
  existed in the NLP engine already but was never wired into
  `BasicSecurityLayer`'s real decision logic - fixed. Also added a specific
  invite-link + raid-language combo check (the "join this server to raid
  with us" pattern), and expanded the raid lexicon to catch common word
  forms ("raiding", "raids") that the bare-verb-only version missed.

### Added - extensibility

- **`frame.registerLayer(name, layerInstance)`** - add a custom security
  layer to the same shared pipeline the built-in four use. Must extend
  `core/Layer`; gets per-guild enable/disable, state persistence, and
  `!gf status`/`!gf metrics` visibility for free, since those now iterate
  `frame.layers` dynamically instead of a fixed list. Does not get an
  automatic button on the 5-button panel (that stays fixed at 5).
- **`frame.registerAction(name, handler)`** - add a punishment action
  beyond ban/kick/timeout/quarantine, referenceable from
  `config.punishment.ladder`. Runs through the same action queue as the
  built-in ones.
- **Per-guild custom prefix** - `!gf prefix set <newPrefix>` /
  `!gf prefix reset`. `config.prefix` always still works everywhere as a
  fallback; an overlapping custom/default pair resolves by trying the
  longer one first.
- **Per-layer log channel routing** - `getLogChannel(guild, layerName)` now
  receives the reporting layer's name as an optional second argument, so
  different layers can route to different channels. Fully backward
  compatible - a `getLogChannel` that only takes one parameter keeps
  working unchanged.

### Added - the owner-only engine dashboard

- **`!gf engine`** - a bot-wide, cross-server dashboard: layer adoption
  across every server, the busiest servers by event volume, AI usage
  (Groq key pool/cooldowns/call volume), queue and cache health, and a live
  feed of the last several things logged anywhere. See `docs/ENGINE.md`.
  Deliberately not listed in `!gf help` - a per-server admin shouldn't know
  it exists, let alone see data about other servers.
- **Its own permission model**, separate from every other command:
  `options.owners` (a list of Discord user IDs) is required regardless, and
  an optional `config.engine.password` is a second factor on the initial
  command only - page-navigation clicks re-check `isOwner()` but don't
  re-prompt for the password. Wrong guesses count toward a lockout
  (`config.engine.maxAttempts`, `config.engine.lockoutMs`), and the command
  message is deleted immediately either way so the password doesn't sit
  visible in channel history.
- **5 pages** (Overview, Servers, AI, Performance, Activity Log), navigated
  with a row of tab buttons - this is a second interactive surface,
  entirely separate from the 5-button panel and the metrics command's own
  button.
- `frame.getEngineReport()` - the raw data behind the dashboard, if you
  want it for your own tooling instead of the rendered message.
- `PerformanceMonitor.recordEvent(name, guildId)` now optionally tracks
  per-guild activity (`topGuildsByActivity()`) alongside the existing
  by-name counters.

## [2.0.0] - 2026-08-01

**Breaking.** Layer activation and the whitelist were accidentally global
across every server the bot is in - this release makes them per-guild, the
way they were always supposed to work, and adds real state persistence.

### Fixed (breaking)

- **Layer on/off state is now per-guild.** Previously, `Layer.enabled` was
  one shared boolean - arming AntiRaid from one server's control panel
  silently armed it for *every* server the bot serves. `getStatus(guildId)`
  accepted a `guildId` but never actually used it. Both are now genuinely
  per-guild: `Layer.enabledGuilds` is a `Set<guildId>`, and
  `frame.enableLayer(name, guildId)` / `frame.disableLayer(name, guildId)`
  now **require** a `guildId` argument. Calling either without one throws,
  rather than silently doing the wrong thing.
- **The whitelist is now per-guild.** `frame.whitelist` was a single global
  `Set<userId>` - whitelisting someone in one server exempted them
  everywhere. It's now a `Map<guildId, Set<userId>>`; use the new
  `frame.addToWhitelist(guildId, userId)`,
  `frame.removeFromWhitelist(guildId, userId)`, and
  `frame.isWhitelisted(guildId, userId)` instead of touching the Map
  directly. The constructor's `whitelist: [...]` option still works exactly
  as before from the outside - it now seeds that list into every guild
  (present and future) the first time GlassFrame sees it, rather than
  applying globally.
- **State now actually persists.** `stateStore` was constructed and then
  never read from or written to anywhere. Every layer toggle and whitelist
  change is now saved per-guild, and restored at startup (and automatically
  for any new guild the bot joins) - see `docs/STATE.md`.
- Fixed a related bug in `BasicSecurityLayer`: its spam-rate buffer was
  keyed by `userId` alone, so the same user active in two different
  servers could incorrectly share one rate-limit window. Now keyed by
  `guildId:userId`.

### Migrating from 1.x

**If you only use the built-in control panel and `!gf` commands, nothing in
your bot's code needs to change** - update the package and you're done; the
panel and commands already operate within a specific server, they just
didn't respect that internally before now.

**If your own code calls these directly, update the call sites:**

| 1.x | 2.0.0 |
|---|---|
| `frame.enableLayer(name)` | `frame.enableLayer(name, guildId)` |
| `frame.disableLayer(name)` | `frame.disableLayer(name, guildId)` |
| `frame.whitelist.add(userId)` | `frame.addToWhitelist(guildId, userId)` |
| `frame.whitelist.delete(userId)` | `frame.removeFromWhitelist(guildId, userId)` |
| `frame.whitelist.has(userId)` | `frame.isWhitelisted(guildId, userId)` |
| `layer.enabled` | `layer.isEnabled(guildId)` |

`options.autoStart` in the constructor is unchanged - still a plain array
of layer names - but now correctly applies per-guild (to every guild the
bot is already in, and automatically to any new one it joins) instead of
turning a layer on everywhere at once.

### Added

- `frame.ready` - a Promise that resolves once every guild the bot was
  already in at construction time has finished restoring its saved state.
  State restoration is async (a real `stateStore` may hit disk/a database),
  so this exists for anyone who wants an explicit guarantee rather than
  relying on the fact that, in practice, it resolves faster than any
  Discord event could arrive.
- `frame.getGuildMetrics(guildId)` - one server's layer status, whitelist
  size, open-case count, and flagged-member count.
- `!gf metrics` now shows **this server's** numbers by default instead of
  bot-wide totals, with one button on the message to switch to the global
  (all-servers) view and back. This is a second button, separate from the
  5-button panel - the panel is still exactly 5 buttons; the metrics
  command now has 1 of its own.
- `ProtocolCache.countByPrefix(prefix)` - powers the per-guild counts above
  by counting non-expired `guildId:userId`-keyed entries for one guild.
- `docs/STATE.md` - the per-guild state and persistence model in full.

## [1.2.1] - 2026-07-31

### Added

- `homepage` and `discord` fields in `package.json` linking to the docs site
  and the support server.

## [1.2.0] - 2026-07-31

Packaging and licensing changes to make this publishable to npm. No
functional/runtime changes to any layer, engine, or command from 1.1.0.

### Added

- `GETTING_STARTED.md` - onboarding guide for adding GlassFrame to a bot you
  already have running (this is the file to hand someone alongside the
  package).
- `scripts/build.js` + `npm run build` - produces `dist/`, the folder that
  actually gets published (see `docs/PUBLISHING.md` in the repo). Uses `terser` for
  real minification if it's installed as a devDependency, otherwise copies
  files through unmodified rather than risking a hand-rolled comment
  stripper - several files contain string literals with `//` in them (the
  Groq endpoint URL, for one), which a naive regex-based stripper would
  corrupt.
- `docs/PUBLISHING.md` - step-by-step npm publishing (same steps on Termux,
  Windows, and Linux), including what `"files"` in `package.json` does and
  does not upload. Kept in the repo for maintainers; not part of this site.
- `.gitignore` for `node_modules/` and the generated `dist/`.

### Changed

- **License**: replaced the MIT license with a custom Limited Use License
  (see `LICENSE`) - grants installing and using the package in your own bot
  (personal or commercial), and withholds copying/modifying/redistributing
  the source or reselling the library itself. This is the real mechanism
  for restricting reuse; a plain `npm install`-able package cannot be made
  technically unreadable once it's on someone's disk, since Node has to
  parse the JS to run it. Not legal advice - see the note at the top of
  `LICENSE`.
- `package.json`: `main` now points at `dist/index.js`; added `files`
  (whitelists what `npm publish` actually uploads - `src/` and the root
  `config.js`/`index.js` are deliberately excluded), `prepublishOnly` (so
  `dist/` can't go stale at publish time), `publishConfig.access: "public"`,
  and placeholder `author`/`repository`/`homepage`/`bugs` fields to fill in.

## [1.1.0] - 2026-07-31

Adds a batch of security extensions, a performance layer, and faster
processing under load - no breaking changes to the 1.0.0 API.

### Added

- `PhishingDatabase` (`src/security/PhishingDatabase.js`) - replaces the
  inline link regex in Basic Security with a dedicated, server-editable
  module: a live blocklist (`!gf phishing add/remove/list`) plus
  general-purpose heuristics (raw-IP links, punycode/homograph domains,
  digit-for-letter brand look-alikes, known shorteners). Ships with an
  empty seed list by design - a hardcoded "known bad domains" list goes
  stale immediately and risks flagging an innocent domain on unverified
  information.
- AntiNuke now watches three more attack surfaces:
- **Emoji/sticker bursts** - mass emoji or sticker deletion/creation is
    tracked the same way channel/role bursts already were.
- **Invite abuse** - an unrestricted invite (no expiry, no use limit)
    created by anyone *other than* recognized trusted staff is flagged;
    the same pattern from real staff is left alone, so this doesn't turn
    into noise on a server that already uses a permanent invite link.
- **Webhook message flooding** - a webhook created moments ago that then
    sends a burst of messages is deleted immediately and traced back to
    whoever created it. This closes a real gap: webhook messages carry no
    guild member, so they were invisible to every other layer's per-member
    checks.
- AntiRaid now also runs **creation-time cluster correlation**
  (`AntiRaidLayer._clusterScore`): a bulk-registered account farm that
  trickles in slowly enough to dodge the join-rate counter is still caught
  by noticing that several recent joiners' accounts were all created
  within the same narrow window.
- `EventQueue` (`src/core/EventQueue.js`) - a bounded-concurrency task
  queue. Every Discord mutation `PunishmentEngine` performs and every
  outbound Groq request now runs through one of two queues
  (`frame.actionQueue`, `frame.aiQueue`) instead of firing unbounded, so a
  burst (raid, nuke, wave of gray-zone messages) can't trip a rate limit by
  hammering it all at once. See `docs/PERFORMANCE.md`.
- `PerformanceMonitor` (`src/core/PerformanceMonitor.js`) and
  `frame.getMetrics()` / `!gf metrics` - lightweight in-process event
  counts and rolling latency averages, plus every cache's stats, in one
  snapshot. No external dependency, no network call.
- Command cooldown (`performance.commandCooldownMs`, default 3s) - prefix
  commands are now throttled per user per guild.
- `docs/PERFORMANCE.md`.

### Changed

- `GroqClient.classify()` split into a thin queued/cached front end and an
  internal `_request()` - the per-request timeout clock now starts only
  once a request actually leaves the queue, so a busy moment can no longer
  cause a false "AI didn't respond in time".
- `PunishmentEngine`: an open case can now **escalate** its action if
  continued signals push the tier past what it was last acted on at (e.g.
  log -> timeout -> kick -> ban within the same incident), instead of
  freezing at whatever action was first decided for the entire debounce
  window. Never downgrades, and a trusted member's ceiling
  (quarantine/alert) is still respected on every re-evaluation.

## [1.0.0] - 2026-07-29

Initial release of **GlassFrame Protocol** - a full architectural rebuild of
the previous `guardian-security-module`, renamed and restructured as a
standalone library rather than a bundle of bot-side modules.

### Added

- Layered architecture (`Layer` base class): AntiRaid, AntiNuke, Basic
  Security, and AI Moderation are now independently toggleable layers that
  share one pipeline instead of four separate modules that each punished on
  their own. `enable()`/`disable()` fully attach/detach a layer's listeners,
  so a disabled layer does zero work.
- `ThreatEngine` - a shared, decaying per-member risk score that every layer
  reports to, so scattered small flags across layers combine into one
  picture instead of triggering separate, redundant reactions. Tiers
  (none/low/medium/high/critical) drive the punishment ladder.
- `RoleAnalyzer` - role hierarchy checks (the protocol never attempts an
  action it lacks permission for) and role **name** analysis: flags roles
  whose name implies authority but hold no real permissions (impersonation
  bait), and roles that quietly hold dangerous permissions under an
  innocuous name. Exposed via the new `!gf scan` command and an automatic
  audit the moment AntiNuke is armed.
- `PunishmentEngine` - the single place that actually bans/kicks/times out/
  quarantines a member. Consults role trust level before acting (a real
  admin is never auto-banned - it's flagged for human review instead), and
  debounces repeat signals against the same member so one incident produces
  one action and one log line, not a stream of them.
- `AIModerationLayer` + `GroqClient` - optional Groq-powered second opinion
  for messages that land in the gray zone between local NLP's clean/dirty
  thresholds. Supports an API key pool with automatic per-key cooldown on
  rate-limit responses, so one exhausted key doesn't take moderation down.
  Verdicts are cached briefly so repeated/near-identical messages don't each
  cost a fresh call. Disabled by default - supply `aiModeration.apiKeys` to
  turn it on.
- `SmartLogger` - merges near-duplicate log events inside a short window
  into a single message with a running count instead of one message per
  event, and enforces a minimum interval between sends per guild.
- 5-button control panel (`!gf panel`) - Activate/Deactivate AntiRaid, AI
  Moderation, AntiNuke, and Basic Security individually, plus a fifth
  "Full Protocol" button that arms or disarms all four together. Every
  layer starts disabled until explicitly armed.
- Prefix-only command surface (`!gf panel|status|scan|whitelist|help`) - no
  slash commands are registered by this library, by design.
- Dangerous permission-grant watchdog and identity-change watchdog inside
  AntiNuke - a role quietly gaining Administrator, or the server's name/
  icon/vanity URL changing, is flagged the moment it happens rather than
  waiting for a repeat-offense threshold. A forensic role/channel snapshot
  is captured the moment any AntiNuke threshold trips.
- Alt-account composite scoring in AntiRaid (account age + username entropy
- missing avatar) alongside the existing join-velocity lockdown.
- Link heuristics in Basic Security: raw-IP links, punycode domains, and
  Discord look-alike domains.
- Pluggable state persistence (`MemoryStateStore` by default,
  `JSONFileStateStore` for restart-safe layer state with zero extra
  dependencies - no native modules, ARM64/Termux-friendly). **Update:**
  as originally shipped in 1.0.0 the store was accepted by the constructor
  but never actually read from or written to - this was fixed for real in
  2.0.0, see `docs/STATE.md`.
- `docs/CACHE_ARCHITECTURE.md` and `docs/PROTOCOL_LAYERS.md` - internal
  architecture references for anyone extending the library.

### Changed

- Renamed from `guardian-security-module` to `glassframe-protocol`.
  Directory layout moved from a flat `modules/` + `utils/` split into
  `src/core`, `src/layers`, `src/moderation`, `src/ai`, `src/logging`,
  `src/ui`, and `src/commands`.
- All punishment decisions moved out of individual layers and into
  `PunishmentEngine`; layers now report signals instead of calling
  `.ban()` / `.kick()` / `.timeout()` directly.
- Components V2 output kept (no legacy `EmbedBuilder` anywhere) and
  extended to support the control panel's button row and event-count
  badges on aggregated log messages.

### Fixed

- Anti-nuke's self-exemption check previously compared `executor.bot === false`
  against the client's own ID, which could never be true for a bot account;
  it now simply skips audit entries the client itself generated.

## [0.x] - guardian-security-module (superseded)

The pre-GlassFrame history shipped as `guardian-security-module` v1.0.0:
local-NLP-only detection, flat `antiRaid.js` / `antiNuke.js` / `security.js`
modules, each acting immediately and independently with no shared scoring,
no role-name analysis, no AI layer, and no control panel.


<!-- ============================================================ -->
<!-- SOURCE: LICENSE  (site page: license.html) -->
<!-- ============================================================ -->

# License

GlassFrame Protocol - Limited Use License

Copyright (c) 2026 GlassFrame Protocol

This is a custom, non-open-source license. It is not MIT, Apache, GPL, or
any OSI-approved license, and it is not legal advice - it is a starting
template. Have an actual lawyer review it before relying on it for
anything commercially significant; enforceability varies by jurisdiction,
and only a licensed attorney can tell you how well specific wording holds
up where you and your users live.

1. GRANT OF USE
   Permission is granted, free of charge, to any person obtaining a copy of
   this software (the "Software") to install and use it, unmodified, as a
   dependency inside their own Discord bot project - personal or
   commercial - subject to the conditions below.
2. RESTRICTIONS
   Without prior written permission from the copyright holder, you may NOT:
   a. Copy, extract, or redistribute the Software's source code on its own
      (as opposed to using it as an installed dependency of your project).
   b. Modify the Software and distribute or publish the modified version,
      under this name or another.
   c. Republish, mirror, or re-upload the Software (or a renamed/modified
      copy of it) to npm or any other package registry.
   d. Reverse-engineer, decompile, or disassemble the Software beyond what
      your local law makes non-waivable.
   e. Remove or alter this license, the copyright notice, or any
      attribution notices contained in the Software.
   f. Resell the Software itself as a standalone product (using it as a
      component inside a bot you sell or host as a service is permitted;
      selling the library itself, or a copy/fork of it, is not).
3. ATTRIBUTION
   Any bot or product that uses this Software should credit "GlassFrame
   Protocol" somewhere reasonably discoverable (a credits command, a
   footer, a README line) unless you have separate written permission to
   omit it.
4. NO WARRANTY
   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
   OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
   LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THE
   SOFTWARE OR ITS USE, INCLUDING ANY ACTION TAKEN BY THE SOFTWARE AGAINST
   A DISCORD SERVER OR ITS MEMBERS (bans, kicks, timeouts, role changes,
   webhook deletions, or any other moderation action).
5. TERMINATION
   This license terminates automatically for anyone who violates Section 2;
   they must stop using the Software and destroy any copies in their
   possession.

To ask for permission beyond what's granted here (bulk redistribution,
white-labeling, a different license for a specific use case), contact the
copyright holder directly.

