diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff6ff98..1b33a34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,52 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+Nothing yet.
+
+## [0.1.0] - 2026-07-06 - AntiLink Guard OSS
+
+This release is a complete rebuild of the repository into **AntiLink Guard
+OSS**, a self-hostable, TypeScript, open-source Discord anti-phishing and
+link-moderation framework, restructured as a pnpm workspace monorepo. It
+replaces the single-file bot described under
+[`[0.1.0-legacy]`](#010-legacy---2024-04-14) below entirely - see
+[`docs/migration-from-old-antilink.md`](./docs/migration-from-old-antilink.md)
+if you were running that version.
+
### Added
-- Professional README with architecture overview, roadmap, and FAQ.
-- `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md`.
-- GitHub issue templates (bug report, feature request) and pull request template.
-- Continuous Integration workflow (lint/build) via GitHub Actions.
-- Dependabot configuration for npm and GitHub Actions updates.
-- `.env.example` documenting required environment variables.
-- Base ESLint (flat config) for linting, with `eslint`, `@eslint/js`, and
- `globals` as devDependencies and a `lint` npm script.
-- `start` npm script (`node index.js`) and `engines` field (Node.js >= 18).
-- `LICENSE` file (MIT), matching the license the README already declared.
-- README: "This repo vs. hosted AntiLink" comparison, an "Official links" table
- (website, dashboard, docs, status, invite, support, privacy, terms), and an
- "Add AntiLink 2.0 to Discord" link to the hosted bot (`invite.antil.ink`,
- Discord App Directory app `1280137058458927134`).
+
+- **`packages/core`** - the detection and policy engine. URL/invite
+ extraction with de-obfuscation (zero-width characters, `hxxp://`,
+ `example[.]com` defanging, markdown links), classification (domain
+ allow/blocklists, a guild-suppliable known-phishing list, URL shorteners,
+ punycode hostnames, Latin/Cyrillic/Greek homoglyph detection, custom
+ regex rules), and a policy engine resolving each message to an
+ `ALLOW`/`WARN`/`BLOCK`/`QUARANTINE` verdict and a
+ `NONE`/`LOG`/`WARN`/`DELETE`/`TIMEOUT` action via a
+ `log`/`warn`/`delete`/`timeout` enforcement mode ladder. No known-phishing
+ domains are hardcoded - that data is always guild- or operator-supplied.
+- **`packages/storage`** - `MemoryStorageAdapter`, `SqliteStorageAdapter`
+ (the self-hosting default), `MysqlStorageAdapter`, and
+ `PostgresStorageAdapter`, all implementing one `StorageAdapter` interface
+ and validated by a shared contract test suite. Config export/import
+ bundles (`exportGuildConfigBundle`/`importGuildConfigBundle`/
+ `parseConfigBundle`, zod-validated) back up or restore a guild's full
+ configuration as JSON.
+- **`packages/discord-bot`** - the discord.js v14 adapter: `/antilink`
+ (status/enable/disable/mode), `/allowlist`, `/blocklist`, `/invites`
+ (allow/block-all), `/logs set-channel`, `/testlink`, and `/config`
+ (export/import). The message pipeline ignores bots and DMs, checks
+ discord.js's own permission signals (`message.deletable`,
+ `member.moderatable`) before ever deleting or timing out, records
+ metadata-only audit log entries (no message content field exists on the
+ type), posts mod-log embeds, and rate-limits enforcement actions per
+ guild via a token-bucket limiter so a spam wave can't drive the bot into
+ Discord's own API rate limits. `/config import` always overwrites the
+ imported bundle's guild ID with the guild the command was run in, so an
+ import can never write into a different server's data.
+- **`packages/cli`** (`antilink`) - `scan`, `test-url`, `init`,
+ `export-config`/`import-config`, and `doctor` (offline health checks:
+ Node version, `.env` presence, config file validity, the better-sqlite3
+ native binding, database directory write access). Runs the exact same
+ detection engine as the bot, entirely offline.
+- **`apps/example-bot`** - a working self-hosted bot assembled from the
+ published packages, with a `DATABASE_DRIVER` switch (sqlite/mysql/postgres)
+ that's actually implemented, a command-registration script, and Docker
+ support (non-root user, persistent volume for SQLite).
+- **`apps/dashboard-lite`** - a dependency-free (`node:http`, no framework),
+ local, explicitly unauthenticated read-only dashboard: bot status, guild
+ config, allowlist/blocklist, and recent audit log entries.
+- Root `docker-compose.yml` for the example bot.
+- 9 documentation pages under `docs/`: getting-started, configuration,
+ rules-engine, discord-setup, privacy, self-hosting, threat-model,
+ api-reference, migration-from-old-antilink.
+- `GOVERNANCE.md` and `ROADMAP.md`.
+- CI (`.github/workflows/ci.yml`) rebuilt for the pnpm monorepo: install,
+ build (topologically ordered), typecheck, lint, format check, and test on
+ Node 20 and 22, with live PostgreSQL and MySQL service containers so
+ every storage adapter's contract tests run for real in CI.
+- `.github/workflows/codeql.yml` - static analysis on push, pull request,
+ and a weekly schedule.
+- A `docker` ecosystem entry in `.github/dependabot.yml` for the example
+ bot's base image.
+- 212 tests across the workspace (unit tests for the detection/policy
+ engine and CLI; a shared adapter contract suite run against all four
+ storage backends, including live tests against a real PostgreSQL
+ instance; discord-bot logic tested against structural fakes of
+ discord.js's Message/Interaction/Client, since there is no live-Discord
+ integration testing without a real bot token and gateway connection;
+ real HTTP integration tests for dashboard-lite's server).
### Changed
-- Revived and re-positioned the project as the open-source edition of the
- AntiLink platform.
-- README now clearly separates this self-hostable open-source bot from the
- **hosted AntiLink 2.0** platform, sourced from the official docs. Replaced the
- earlier placeholder platform names (Security/Analytics/Billing/AI) with the
- real hosted product set (dashboard, Member Defense, Verify, Automod, Honeypot,
- Emergency Lockdown, custom bot, Free/Premium/AntiLink Premium plans), all
- marked as a **separate hosted product** and not shipped in this repository.
-- **Breaking:** upgraded from discord.js v13 to **v14** and ported `index.js`
- to the v14 API (`GatewayIntentBits`, `Events`), including the now-required
- `MessageContent` privileged intent.
-- **Breaking:** moved the hardcoded whitelisted-channel and bypass-role IDs out
- of the source into the `WHITELISTED_CHANNEL_IDS` and `IGNORED_ROLE_IDS`
- environment variables (comma-separated lists).
-- `WEBHOOK_URL` is now optional — without it the bot still filters links and
- simply skips webhook logging; a single `WebhookClient` is reused instead of
- being constructed for every deletion.
-
-### Fixed
-- Messages from bots/webhooks and DMs are now ignored, and system messages with
- no member object no longer crash the role-bypass check.
+
+- **Breaking, in every sense** - the single `index.js` file is gone. Its
+ hardcoded whitelisted-channel/ignored-role arrays and `WEBHOOK_URL`
+ webhook logging are replaced by per-guild database-backed configuration
+ and `/logs set-channel`. See
+ [`docs/migration-from-old-antilink.md`](./docs/migration-from-old-antilink.md).
+- Tooling: npm → pnpm, plain JS → strict TypeScript, a single package.json →
+ a pnpm workspace of 4 packages and 2 apps, `node index.js` → a build step
+ required before running anything (packages resolve each other via built
+ `dist/` output).
+- `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, the PR template,
+ and the issue templates were rewritten for the new monorepo and Node 20+;
+ stale references to an unrelated hosted product's support/docs links were
+ removed (`CODE_OF_CONDUCT.md`'s enforcement contact, the issue template
+ config).
+- `package.json`'s `homepage` field now points at this repository instead
+ of an unrelated hosted product's website.
+
+### Removed
+
+- The old `index.js`, its npm scripts, and its env vars (`WEBHOOK_URL`,
+ `WHITELISTED_CHANNEL_IDS`, `IGNORED_ROLE_IDS`) - see "Changed" above for
+ what replaces each one.
---
-## [0.1.0] - 2024-04-14
+## [0.1.0-legacy] - 2024-04-14
+
+The original `Anti-Links-Discord-Bot`, before the AntiLink Guard OSS
+rewrite above. Kept here for historical record and for anyone migrating
+from it.
- Initial anti-link Discord bot: automatic link detection and removal in
configured channels, channel whitelisting, role-based bypass, and webhook
notifications.
-[Unreleased]: https://github.com/timeout187/Anti-Links-Discord-Bot/compare/v0.1.0...HEAD
-[0.1.0]: https://github.com/timeout187/Anti-Links-Discord-Bot/releases/tag/v0.1.0
+[Unreleased]: https://github.com/timeout187/antilink-guard/compare/v0.1.0...HEAD
+[0.1.0]: https://github.com/timeout187/antilink-guard/releases/tag/v0.1.0
+[0.1.0-legacy]: https://github.com/timeout187/antilink-guard/releases/tag/v0.1.0-legacy
diff --git a/README.md b/README.md
index 29da133..f622693 100644
--- a/README.md
+++ b/README.md
@@ -1,307 +1,216 @@
-

+# AntiLink Guard OSS
-# AntiLink
-
-**Open-source Discord link moderation for communities that need clean, safe chat.**
+**Open-source Discord anti-phishing and link moderation framework.**
[](./LICENSE)
-[](https://nodejs.org)
+[](https://nodejs.org)
[](https://discord.js.org)
-[](https://github.com/timeout187/Anti-Links-Discord-Bot/actions/workflows/ci.yml)
+[](https://pnpm.io)
+[](https://www.typescriptlang.org)
+[](https://github.com/timeout187/antilink-guard/actions/workflows/ci.yml)
+[](https://github.com/timeout187/antilink-guard/actions/workflows/codeql.yml)
[](./CONTRIBUTING.md)
-[](./CODE_OF_CONDUCT.md)
-
-[Website][website] · [Documentation][docs] · [Dashboard][dashboard] · [Status][status] · [Discord][discord] · [Report a Bug][issues]
-
-**Want the fully-featured hosted bot instead of self-hosting?**
-[][invite]
---
-> **Note on scope.** This repository is the original, **self-hostable open-source** AntiLink bot. It does automatic link filtering and nothing else. The **hosted AntiLink platform** ([AntiLink 2.0][invite]) is a separate, much larger product — dashboard, paid plans, raid defense, verification, and more — and **none of those features are in this repository**. See [This repo vs. hosted AntiLink](#this-repo-vs-hosted-antilink) for the difference, and the [Roadmap](#roadmap) for what the hosted platform offers. Anything marked *Planned* or *hosted* does not ship here.
-
-## Table of Contents
+## The problem
-- [Introduction](#introduction)
-- [Features](#features)
-- [This repo vs. hosted AntiLink](#this-repo-vs-hosted-antilink)
-- [Official links](#official-links)
-- [Architecture](#architecture)
-- [Installation](#installation)
-- [Quick Start](#quick-start)
-- [Commands](#commands)
-- [Configuration](#configuration)
-- [Screenshots](#screenshots)
-- [Roadmap](#roadmap)
-- [Contributing](#contributing)
-- [Security](#security)
-- [License](#license)
-- [Support](#support)
-- [Documentation](#documentation)
-- [FAQ](#faq)
+Discord communities get hit with the same handful of attacks constantly:
+phishing links dressed up as free-Nitro giveaways, scam Discord invites,
+punycode/homoglyph domains that look identical to legitimate ones at a
+glance, and links deliberately obfuscated (`hxxps://`, `example[.]com`,
+zero-width characters) to slip past naive keyword filters. Most
+self-hostable anti-link bots do a plain `.includes("http")` check and call
+it done.
-## Introduction
-
-**AntiLink** is a lightweight, self-hostable Discord bot that automatically detects and removes messages containing links in the channels you choose. It was originally built to keep a busy community's general chat free of unwanted and malicious links, and it is the open-source origin of the wider **AntiLink** moderation platform.
-
-The open-source bot is intentionally small and easy to audit: you can read the whole thing, host it yourself, and know exactly what it does with your server's messages.
-
-If you'd rather not self-host, the same team runs a fully-featured **hosted** bot — **AntiLink 2.0** — with a web dashboard, invite/phishing/raid protection, verification, and more. You can [add it to your server in one click][invite]; it's a separate product from this repository (see [below](#this-repo-vs-hosted-antilink)).
+**AntiLink Guard OSS** is a real detection and policy engine, not a regex
+one-liner - and it's yours to run, read, and modify.
## Features
-Currently implemented in this repository:
-
-- 🔗 **Automatic link detection** — scans messages in configured channels and removes those containing links.
-- ✅ **Channel whitelisting** — designate channels where links are always allowed.
-- 🛡️ **Role-based bypass** — members with configured roles (e.g. staff/mods) are exempt from filtering.
-- 📣 **Webhook notifications** — report removals/actions to a channel via a Discord webhook (`WEBHOOK_URL`).
-- 🪶 **Minimal footprint** — plain Node.js + discord.js, no database required to get started.
-
-> If you spot a discrepancy between this list and the actual code, it's a bug in the docs — please [open an issue][issues]. We keep this section limited to behavior that actually ships.
-
-## This repo vs. hosted AntiLink
-
-There are two ways to run AntiLink. **This repository is the first one.**
-
-| | **This repo** (open-source) | **AntiLink 2.0** ([hosted][invite]) |
-| --- | --- | --- |
-| How you run it | Self-host the Node.js bot yourself | Invite the hosted bot — nothing to host |
-| Link filtering | ✅ Automatic `http(s)://` removal | ✅ Invites, external links, phishing, risk scoring |
-| Config | Env vars in `.env` | Slash commands + [web dashboard][dashboard] |
-| Whitelist | Channels & roles (via env) | Users, roles, channels, categories, domains |
-| Slash commands | ❌ (none) | ✅ `/setup`, `/config`, `/whitelist`, `/guard`, … |
-| Raid / member defense | ❌ | ✅ Member Defense, Honeypot, Emergency Lockdown |
-| Verification, Automod | ❌ | ✅ Verify gate + Automod presets |
-| Cost | Free (MIT) | Free tier + paid plans |
-
-This README documents **only the open-source bot**. For everything in the right-hand column, see the [hosted docs][docs] or [add AntiLink 2.0 to your server][invite].
-
-## Official links
-
-| | |
-| --- | --- |
-| 🌐 Website | [antil.ink][website] |
-| 🛡️ Dashboard | [dashboard.antil.ink][dashboard] |
-| 📖 Documentation | [docs.antil.ink][docs] |
-| 📈 Status | [status.antil.ink][status] |
-| ➕ Add AntiLink 2.0 (hosted) | [invite.antil.ink][invite] |
-| 💬 Support & community | [support.antil.ink][discord] |
-| 🔒 Privacy Policy | [docs.antil.ink/docs/legal/privacy](https://docs.antil.ink/docs/legal/privacy) |
-| 📄 Terms of Service | [docs.antil.ink/docs/legal/terms](https://docs.antil.ink/docs/legal/terms) |
-
-> **Only trust the domains listed above.** AntiLink will never DM you asking for your password, token, or payment details.
+Everything below is implemented and tested in this repository:
+
+- 🔗 **Real link detection** - not just `https?://`. Catches markdown
+ links, bare `www.` domains, Discord invites, and links deliberately
+ obfuscated with `hxxp://`, `example[.]com`, or zero-width characters.
+- 🕵️ **Classification, not just matching** - domain allow/blocklists, a
+ guild-suppliable known-phishing list, common URL shorteners, punycode
+ hostnames, and Latin/Cyrillic/Greek homoglyph mixing, plus your own
+ custom regex rules.
+- ⚖️ **A real policy engine** - every message gets a score and one of four
+ verdicts (`ALLOW`/`WARN`/`BLOCK`/`QUARANTINE`), mapped to an action
+ through a `log → warn → delete → timeout` enforcement ladder you control
+ per server.
+- 🔐 **Permission-safe by construction** - the bot checks its own Discord
+ permissions before every delete/timeout and fails safe (logs and skips)
+ rather than crashing or assuming success.
+- 🗄️ **Your database, your choice** - memory (for tests), SQLite (the
+ self-hosting default), MySQL, or PostgreSQL, all behind one interface.
+- 📊 **Metadata-only audit logging** - the audit log type has no field
+ capable of holding message content, by design. See
+ [`docs/privacy.md`](./docs/privacy.md).
+- 🧰 **A real CLI** - `antilink scan`/`test-url` run the exact same engine
+ the bot uses, entirely offline, no Discord connection required.
+- 🖥️ **A minimal local dashboard** - read-only, unauthenticated by design,
+ meant for `localhost` (see [`apps/dashboard-lite`](./apps/dashboard-lite)).
+
+No hardcoded "known phishing domain" database is bundled anywhere in this
+codebase - that data is always something you (or a feed you choose) supply.
+See [`docs/rules-engine.md`](./docs/rules-engine.md#why-knownphishingdomains-is-always-empty-out-of-the-box).
## Architecture
-The open-source bot follows a simple, single-process design:
-
+```mermaid
+flowchart TD
+ subgraph Discord
+ MSG[messageCreate event]
+ CMD[Slash command interaction]
+ end
+
+ subgraph "@antilink-guard/discord-bot"
+ MSG --> PIPE[Moderation pipeline]
+ CMD --> ROUTE[Command router]
+ PIPE --> ENFORCE[enforce.ts
permission-gated delete/timeout]
+ ENFORCE --> MODLOG[Mod-log embed]
+ end
+
+ subgraph "@antilink-guard/core"
+ EXTRACT[extractLinks
de-obfuscation]
+ CLASSIFY[classifyLink
scoring]
+ POLICY[evaluateMessage
verdict + action]
+ EXTRACT --> CLASSIFY --> POLICY
+ end
+
+ PIPE --> EXTRACT
+ ROUTE --> POLICY
+
+ subgraph "@antilink-guard/storage"
+ DB[(SQLite / MySQL / PostgreSQL)]
+ end
+
+ POLICY <--> DB
+ ENFORCE --> DB
+
+ CLI["@antilink-guard/cli
(scan / test-url / doctor)"] --> EXTRACT
+ DASH["apps/dashboard-lite
(local, read-only)"] --> DB
```
-┌─────────────────────────────────────────────┐
-│ Discord Gateway │
-└───────────────────────┬─────────────────────┘
- │ message events
- ▼
-┌─────────────────────────────────────────────┐
-│ AntiLink Bot │
-│ │
-│ ┌────────────┐ ┌───────────────────────┐ │
-│ │ Config / │──▶│ Message Handler │ │
-│ │ .env │ │ (link detection, │ │
-│ └────────────┘ │ whitelist + bypass) │ │
-│ └───────────┬───────────┘ │
-│ │ action │
-│ ▼ │
-│ ┌───────────────────────┐ │
-│ │ Moderation actions │ │
-│ │ (delete message) │ │
-│ └───────────┬───────────┘ │
-│ │ notify │
-│ ▼ │
-│ ┌───────────────────────┐ │
-│ │ Webhook logger │ │
-│ └───────────────────────┘ │
-└─────────────────────────────────────────────┘
-```
-
-Slash-command administration is *Planned* (see [Roadmap](#roadmap)); today the
-bot is driven entirely by the message-event path shown above plus environment
-configuration.
-
-**Design principles**
-
-- **Self-contained.** Runs as a single Node.js process; no external database required for core filtering.
-- **Auditable.** Small enough to read end-to-end before you trust it in your server.
-- **Configuration over code.** Secrets live in `.env`; behavior is driven by configuration (see [Configuration](#configuration)).
-
-> The commercial AntiLink platform uses a different, service-oriented architecture (dashboard, API, and proprietary detection) that is **not** included here.
-## Installation
+`packages/core` has no dependency on Discord or on any storage backend -
+it's a pure detection/policy library you can use standalone (the CLI does
+exactly that). See [`docs/rules-engine.md`](./docs/rules-engine.md) for the
+full pipeline and [`docs/api-reference.md`](./docs/api-reference.md) for
+every package's exports.
-### Prerequisites
-
-- [Node.js](https://nodejs.org) **18 or newer** and npm
-- A Discord bot application and token — see [Discord's developer docs](https://discord.com/developers/docs/quick-start/getting-started)
-- The bot must be invited to your server with permission to **Read Messages/View Channels**, **Manage Messages**, and (if you use webhook logging) a webhook in your log channel
-
-### Steps
+## Quick start
```bash
-# 1. Clone the repository
-git clone https://github.com/timeout187/Anti-Links-Discord-Bot.git
-cd Anti-Links-Discord-Bot
+git clone https://github.com/timeout187/antilink-guard.git
+cd antilink-guard
+pnpm install
+pnpm run build
-# 2. Install dependencies
-npm install
+cp apps/example-bot/.env.example apps/example-bot/.env
+# edit apps/example-bot/.env: DISCORD_TOKEN, DISCORD_CLIENT_ID
-# 3. Create your environment file
-cp .env.example .env
-# then edit .env and fill in your values
-
-# 4. Start the bot
-npm start
+pnpm --filter @antilink-guard/example-bot run register-commands
+pnpm --filter @antilink-guard/example-bot run start
```
-## Quick Start
-
-1. **Create a bot** in the [Discord Developer Portal](https://discord.com/developers/applications), copy its **token**, and enable the **Message Content Intent** under *Bot → Privileged Gateway Intents*.
-2. **Invite the bot** to your server with the permissions listed above.
-3. **Create a webhook** in the channel where you want moderation logs, and copy its URL (optional but recommended).
-4. Fill in `.env`, including your whitelisted channels and bypass roles (see [Configuration](#configuration)):
-
- ```dotenv
- DISCORD_TOKEN=your-bot-token-here
- WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy
- WHITELISTED_CHANNEL_IDS=123456789012345678,234567890123456789
- IGNORED_ROLE_IDS=345678901234567890
- ```
-
-5. Run `npm start`. Post a link in a filtered channel from a non-exempt account to confirm it's removed.
-
-## Commands
+Full walkthrough (Discord Developer Portal setup, required permissions,
+Message Content Intent): [`docs/getting-started.md`](./docs/getting-started.md).
-The community edition currently has **no slash commands** — it runs entirely as
-**automatic message filtering**, configured through environment variables.
+### Docker Compose
-| Capability | Status | Description |
-| ---------- | ------ | ----------- |
-| Automatic link filtering | ✅ Available | Deletes any message containing an `http(s)://` link, unless the channel is whitelisted or the author has a bypass role. Runs continuously; no command needed. |
-| Webhook moderation log | ✅ Available | Posts a note to your log webhook each time a message is removed. |
-| `/antilink …` slash commands | 🗓️ Planned here | This open-source bot has no slash commands yet (on the [Roadmap](#roadmap)). The **hosted [AntiLink 2.0][invite]** bot already has a full slash-command suite (`/setup`, `/config`, `/whitelist`, `/guard`, …) — see the [hosted docs][docs]. |
+```bash
+cp apps/example-bot/.env.example apps/example-bot/.env
+docker compose up --build -d
+```
+Builds the whole workspace inside the container and runs the bot under a
+non-root user, with a persistent volume for its SQLite database. See
+[`docs/self-hosting.md`](./docs/self-hosting.md) for switching to MySQL or
+PostgreSQL instead.
-## Configuration
+## Usage
-Secrets are provided through environment variables (`.env`). Copy `.env.example` to `.env` and fill in:
+```
+/antilink enable
+/antilink mode block
-| Variable | Required | Description |
-| -------- | -------- | ----------- |
-| `DISCORD_TOKEN` | ✅ | Your Discord bot token. |
-| `WEBHOOK_URL` | Optional | Discord webhook URL for moderation logs. Omit to disable webhook logging. |
-| `WHITELISTED_CHANNEL_IDS` | Optional | Comma-separated channel IDs where links are always allowed. |
-| `IGNORED_ROLE_IDS` | Optional | Comma-separated role IDs whose members bypass link filtering. |
+/allowlist add domain:cdn.example.com
+/blocklist add domain:free-nitro-gift.ru
-> **Never commit your `.env` file or bot token.** `.gitignore` already excludes `.env`. If a token is ever exposed, regenerate it immediately in the Developer Portal.
+/invites block-all enabled:true
+/invites allow invite:https://discord.gg/your-partner-server
-## Screenshots
+/logs set-channel channel:#mod-log
-_Coming soon — captures of a link removal and the webhook moderation log will live in `docs/screenshots/`._
+/testlink url:https://xn--e1aybc.xn--p1ai
+/config export
+```
-## Roadmap
+Every admin command requires **Manage Server**; `/testlink` is open to
+everyone. Full reference: [`docs/configuration.md`](./docs/configuration.md).
-Direction for **this open-source bot**. Unchecked items are not shipped here yet.
+Prefer the command line? The exact same engine, no Discord required:
-- [x] Migrate configuration out of code into an environment-driven setup
-- [ ] Slash-command administration (`/antilink …`)
-- [ ] Configurable actions beyond deletion (warn, timeout, escalate)
-- [ ] Per-guild settings with optional persistence
-- [ ] Structured logging + optional webhook embeds
-- [ ] Test suite and typed codebase
+```bash
+antilink scan "check out hxxps://free-nitro[.]ru/claim"
+antilink test-url https://bit.ly/abc123
+antilink doctor
+```
-### The hosted AntiLink platform
+## Privacy & security
-Many of the above already exist — and much more — in the **hosted AntiLink 2.0** bot, which is a **separate product, not part of this repository**. It is not on this repo's roadmap; it's listed so you know what's available if you'd rather not self-host. [Add it to your server][invite] or read the [hosted docs][docs].
+- **No telemetry.** This project doesn't phone home anywhere.
+- **Metadata-only audit logs** - see [`docs/privacy.md`](./docs/privacy.md)
+ for exactly what's stored (and what deliberately isn't).
+- **Threat model**: [`docs/threat-model.md`](./docs/threat-model.md) - what
+ this framework does and doesn't defend against.
+- **Reporting a vulnerability**: see [`SECURITY.md`](./SECURITY.md) -
+ please don't open a public issue for security problems.
-- 🛡️ **[Web dashboard][dashboard]** — manage protection, whitelists, and logs from the browser
-- 🔗 **Advanced link protection** — Discord invites, external links, phishing & risk scoring, enforcement modes (delete / quarantine / warn)
-- 🧰 **Full slash-command suite** — `/setup`, `/config`, `/whitelist`, `/checklink`, `/language`, and more
-- 🚨 **Member Defense** — raid detection, account-age screening, quarantine automation *(AntiLink Premium)*
-- 🍯 **Honeypot trap channels** & **Emergency Lockdown**
-- ✅ **Verify** — one-click member verification gate
-- 🤖 **Automod** — preset spam / bad-words / caps / mention filters
-- 🎭 **Custom bot** — run AntiLink under your own name & avatar *(AntiLink Premium)*
-- 💳 **Plans** — Free, Premium, and AntiLink Premium (billing via Paddle)
+## Repository layout
-Have an idea for the **open-source bot**? [Open a feature request][issues].
+```
+apps/
+ example-bot/ a working self-hosted bot built on the packages below
+ dashboard-lite/ a local, read-only dashboard
+packages/
+ core/ URL/invite extraction, classification, policy engine
+ storage/ memory / SQLite / MySQL / PostgreSQL adapters
+ discord-bot/ the discord.js v14 adapter + moderation pipeline
+ cli/ the `antilink` command-line tool
+docs/ the guides linked throughout this README
+```
## Contributing
-Contributions are welcome and appreciated. Please read **[CONTRIBUTING.md](./CONTRIBUTING.md)** and our **[Code of Conduct](./CODE_OF_CONDUCT.md)** before you start.
-
-The short version:
-
-1. Fork the repo and create a branch from `main`.
-2. Make your change with clear, focused commits.
-3. Run linting locally before pushing.
-4. Open a pull request using the template and describe what and why.
-
-Good first contributions: documentation fixes, config externalization, tests, and the *Planned* roadmap items above.
+Contributions are welcome - see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for
+the development setup (pnpm workspace, build order, testing) and
+[`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md). Governance model:
+[`GOVERNANCE.md`](./GOVERNANCE.md).
-## Security
+## Roadmap
-Please **do not** report security vulnerabilities through public issues. See **[SECURITY.md](./SECURITY.md)** for how to report responsibly.
+See [`ROADMAP.md`](./ROADMAP.md) for what's shipped, what's next, and what's
+explicitly out of scope for this project.
## License
-Distributed under the **MIT License**. See [LICENSE](./LICENSE) for details.
-
-## Support
-
-- 💬 **Community help:** [AntiLink Discord][discord]
-- 🐛 **Bugs & features:** [GitHub Issues][issues]
-- 📖 **Guides:** [Documentation][docs]
+[MIT](./LICENSE).
-## Documentation
+## Background
-This README is the complete guide to the **self-hosted open-source bot**. The documentation site at **[docs.antil.ink][docs]** covers the **hosted AntiLink 2.0** platform — dashboard, slash commands, plans, and the security suite — which is a separate product from this repository.
-
-## FAQ
-
-**Is AntiLink free?**
-The bot in this repository is free and open-source under the MIT license. Hosted/commercial platform products are separate.
-
-**Do I need a database?**
-No. Core link filtering runs without one. Persistence is only needed for *Planned* features like per-guild settings.
-
-**Why are my links not being deleted?**
-Check that (1) the **Message Content Intent** is enabled, (2) the bot has **Manage Messages** in that channel, (3) the channel isn't whitelisted, and (4) your account doesn't hold a bypass role.
-
-**Does it work with the latest discord.js?**
-The bot targets discord.js v14 and Node.js 18+. If you hit a version issue, please [open an issue][issues].
-
-**What's the difference between this and "AntiLink 2.0"?**
-This repo is the original, self-hostable open-source bot — automatic link filtering, configured with env vars. **AntiLink 2.0** is the separate **hosted** product with a dashboard, slash commands, raid defense, verification, and paid plans. [Add it to your server][invite] or see the [comparison](#this-repo-vs-hosted-antilink).
-
-**Can I use this commercially?**
-Yes — MIT permits commercial use. You're responsible for your own hosting and compliance.
-
----
-
-
-
-Built and maintained by the AntiLink community. Star ⭐ the repo if it's useful!
-
-
+AntiLink Guard OSS is based on lessons learned from running AntiLink, a
+Discord moderation bot used across 100+ servers. This repository extracts
+the reusable moderation, URL detection, and policy engine into a
+self-hostable open-source toolkit for the community.
-
-[website]: https://antil.ink "AntiLink website"
-[docs]: https://docs.antil.ink/ "AntiLink documentation"
-[dashboard]: https://dashboard.antil.ink "AntiLink web dashboard (hosted)"
-[status]: https://status.antil.ink "AntiLink status page"
-[invite]: https://invite.antil.ink "Add the hosted AntiLink 2.0 bot to your server"
-[discord]: https://support.antil.ink/ "AntiLink support & community Discord"
-[issues]: https://github.com/timeout187/Anti-Links-Discord-Bot/issues
+This repository contains **only** the open-source framework described
+above. It is not affiliated with, and does not include, any hosted or
+commercial product that happens to share part of its name.
diff --git a/docs/BRANDING.md b/docs/BRANDING.md
index f849343..6fa5f44 100644
--- a/docs/BRANDING.md
+++ b/docs/BRANDING.md
@@ -13,36 +13,36 @@ blocked links and server protection.
## Color Palette
-| Role | Hex | RGB | Preview |
-| ---- | --- | --- | ------- |
-| **AntiLink Blue** (primary) | `#00A3F0` | `0, 163, 240` |  |
-| **Blue Shadow** (shaded face) | `#0080C1` | `0, 128, 193` |  |
-| **Blue Highlight** (bevel) | `#25B0F1` | `37, 176, 241` |  |
-| **Blue Tint** (light bevel) | `#74CBF4` | `116, 203, 244` |  |
-| **White** (icon / outline) | `#FFFFFF` | `255, 255, 255` |  |
+| Role | Hex | RGB | Preview |
+| ----------------------------- | --------- | --------------- | -------------------------------------------------------- |
+| **AntiLink Blue** (primary) | `#00A3F0` | `0, 163, 240` |  |
+| **Blue Shadow** (shaded face) | `#0080C1` | `0, 128, 193` |  |
+| **Blue Highlight** (bevel) | `#25B0F1` | `37, 176, 241` |  |
+| **Blue Tint** (light bevel) | `#74CBF4` | `116, 203, 244` |  |
+| **White** (icon / outline) | `#FFFFFF` | `255, 255, 255` |  |
### Suggested neutrals (for docs/site UI)
-| Role | Hex |
-| ---- | --- |
-| Ink / text | `#0B1B2B` |
-| Muted text | `#5A6B7B` |
+| Role | Hex |
+| -------------------- | --------- |
+| Ink / text | `#0B1B2B` |
+| Muted text | `#5A6B7B` |
| Surface / background | `#FFFFFF` |
-| Subtle border | `#E3ECF3` |
+| Subtle border | `#E3ECF3` |
## Design Tokens
```css
:root {
- --antilink-blue: #00A3F0;
- --antilink-blue-shadow: #0080C1;
- --antilink-blue-highlight: #25B0F1;
- --antilink-blue-tint: #74CBF4;
- --antilink-white: #FFFFFF;
+ --antilink-blue: #00a3f0;
+ --antilink-blue-shadow: #0080c1;
+ --antilink-blue-highlight: #25b0f1;
+ --antilink-blue-tint: #74cbf4;
+ --antilink-white: #ffffff;
- --antilink-ink: #0B1B2B;
- --antilink-muted: #5A6B7B;
- --antilink-border: #E3ECF3;
+ --antilink-ink: #0b1b2b;
+ --antilink-muted: #5a6b7b;
+ --antilink-border: #e3ecf3;
}
```
diff --git a/docs/api-reference.md b/docs/api-reference.md
new file mode 100644
index 0000000..5c054a5
--- /dev/null
+++ b/docs/api-reference.md
@@ -0,0 +1,128 @@
+# API Reference
+
+The public exports of each package, as actually declared in each
+package's `src/index.ts`. If you're building your own bot on top of this
+framework instead of using `apps/example-bot` directly, this is what you
+import.
+
+## `@antilink-guard/core`
+
+No Discord or storage dependency - pure detection/policy logic.
+
+```ts
+import {
+ evaluateMessage,
+ extractLinks,
+ classifyLink,
+ normalizeUrl,
+ stripZeroWidthChars,
+ undefangText,
+ isPunycodeHostname,
+ hasMixedScriptConfusables,
+ REASON_SEVERITY,
+ VERDICT_THRESHOLDS,
+ DEFAULT_URL_SHORTENER_DOMAINS,
+} from '@antilink-guard/core';
+```
+
+| Export | Signature | What it does |
+| -------------------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
+| `evaluateMessage` | `(rawText: string, context: ScanContext, policy: PolicyConfig) => ScanResult` | The full pipeline: bypasses → extraction → classification → verdict/action |
+| `extractLinks` | `(rawText: string) => ExtractedLink[]` | Finds and de-obfuscates links/invites in text |
+| `classifyLink` | `(link: ExtractedLink, policy: PolicyConfig, rawMessageText: string) => LinkClassification` | Scores a single already-extracted link |
+| `normalizeUrl` | `(rawUrl: string) => { url: string; hostname: string } \| undefined` | Lowercases the host, strips tracking params, returns `undefined` for unparseable input |
+| `isPunycodeHostname` / `hasMixedScriptConfusables` | `(hostname: string) => boolean` | The two homoglyph-related checks, individually |
+| `REASON_SEVERITY` | `Record` | The severity table - see [rules-engine.md](./rules-engine.md) |
+| `VERDICT_THRESHOLDS` | `{ warn: 1, block: 30, quarantine: 60 }` | The score cutoffs for each verdict |
+
+Key types: `Verdict`, `DetectionReason`, `EnforcementMode`,
+`ModerationActionType`, `ExtractedLink`, `LinkClassification`,
+`RegexRule`, `ChannelRule`, `PolicyConfig`, `ScanContext`, `ScanResult`.
+
+```ts
+const result = evaluateMessage(
+ message.content,
+ { guildId, channelId, authorId, authorRoleIds: [], mentionCount: 0 },
+ policy,
+);
+// result.verdict, result.action, result.score, result.reasons, result.matchedLinks
+```
+
+## `@antilink-guard/storage`
+
+```ts
+import {
+ MemoryStorageAdapter,
+ SqliteStorageAdapter,
+ MysqlStorageAdapter,
+ PostgresStorageAdapter,
+ createDefaultGuildConfig,
+ exportGuildConfigBundle,
+ importGuildConfigBundle,
+ parseConfigBundle,
+ configBundleSchema,
+} from '@antilink-guard/storage';
+```
+
+All four adapter classes implement the same `StorageAdapter` interface:
+`init()`, `close()`, `getGuildConfig`/`upsertGuildConfig`,
+`list/add/removeAllowlistEntry`, `list/add/removeBlocklistEntry`,
+`list/add/removeInviteRule`, `add/listAuditLogEntries`,
+`add/listScanResults`, `add/listModerationActions`.
+
+```ts
+const storage = new SqliteStorageAdapter({ filename: './antilink.sqlite' });
+await storage.init();
+const config = (await storage.getGuildConfig(guildId)) ?? createDefaultGuildConfig(guildId);
+```
+
+Config bundle helpers (used by both `/config export`/`import` and the CLI):
+
+```ts
+const bundle = await exportGuildConfigBundle(storage, guildId);
+const validated = parseConfigBundle(JSON.parse(fileContents)); // throws on invalid shape
+await importGuildConfigBundle(storage, validated); // replaceExisting: true by default
+```
+
+Data model types: `GuildConfig`, `AllowlistEntry`, `BlocklistEntry`,
+`InviteRule`, `AuditLogEntry`, `ScanResultRecord`, `ModerationActionRecord`.
+
+## `@antilink-guard/discord-bot`
+
+```ts
+import { createBot, registerCommands, TokenBucketRateLimiter } from '@antilink-guard/discord-bot';
+
+const client = createBot({ storage }); // wires up messageCreate + interactionCreate
+await client.login(token);
+```
+
+| Export | Purpose |
+| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `createBot(options: CreateBotOptions) => Client` | Builds a ready-to-login discord.js `Client` with the moderation pipeline and command routing already attached |
+| `registerCommands(options: RegisterCommandsOptions) => Promise` | Registers all slash commands via the Discord REST API, guild-scoped or global |
+| `handleMessageCreate` / `handleInteractionCreate` | The two event handlers `createBot` wires up, exported individually if you want to compose your own `Client` |
+| `enforceScanResult` | Applies a `ScanResult` to a message: permission-gated delete/timeout, audit log + moderation action recording |
+| `buildPolicyConfig(inputs: PolicyInputs) => PolicyConfig` | Maps stored `GuildConfig` + lists into the shape `evaluateMessage` expects |
+| `buildModLogEmbed` / `sendModLog` | Build/send the mod-log embed |
+| `TokenBucketRateLimiter` | The generic rate limiter used to cap moderation actions |
+| `allCommands`, `commandsByName`, `getCommandJSONBodies()` | The slash command registry, if you want to register a subset or inspect the JSON payloads |
+
+## `@antilink-guard/cli`
+
+Primarily a command-line tool (`antilink scan|test-url|init|export-config|import-config|doctor` -
+see [getting-started.md](./getting-started.md#trying-the-cli-without-any-discord-setup-at-all)),
+not intended to be imported as a library. `createProgram()` is exported from
+`src/index.ts` if you need to embed its commander.js program elsewhere.
+
+## Dashboard-lite's HTTP API
+
+`apps/dashboard-lite` exposes a small local JSON API consumed by its own
+frontend (see [privacy.md](./privacy.md) for why it has no auth):
+
+| Route | Returns |
+| --------------------------------------------- | ----------------------------------------------------------------------------- |
+| `GET /api/guild/:guildId/status` | `{ guildConfig, counts: { allowlist, blocklist, inviteRules }, lastAuditAt }` |
+| `GET /api/guild/:guildId/config` | The raw `GuildConfig` |
+| `GET /api/guild/:guildId/allowlist` | `AllowlistEntry[]` |
+| `GET /api/guild/:guildId/blocklist` | `BlocklistEntry[]` |
+| `GET /api/guild/:guildId/audit-logs?limit=50` | `AuditLogEntry[]`, newest first |
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..0fa18a1
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,97 @@
+# Configuration
+
+There are **two separate configuration surfaces** in this project. Knowing
+which one you're touching matters:
+
+| | Per-guild bot configuration | Local policy file |
+| ----------- | ------------------------------------------------ | ------------------------------------------- |
+| Used by | `packages/discord-bot` at runtime (the live bot) | `packages/cli`'s `scan`/`test-url` commands |
+| Stored in | Your database (`packages/storage`) | A local `antilink.config.json` file |
+| Changed via | Slash commands | Editing the file directly |
+| Scope | One Discord server (guild) | Whatever you're running the CLI against |
+
+## Per-guild bot configuration
+
+This is what actually governs moderation in your Discord server. It's the
+`GuildConfig` model in `packages/storage`, plus three related lists
+(allowlist, blocklist, invite rules), all keyed by guild ID.
+
+| Field | Slash command | Default |
+| ---------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------- |
+| `enabled` | `/antilink enable` / `/antilink disable` | `true` |
+| `mode` | `/antilink mode ` | `delete` (see table below for what each command word maps to) |
+| `logChannelId` | `/logs set-channel channel:<#channel>` | unset |
+| `blockAllInvites` | `/invites block-all [enabled:]` | `false` |
+| domain allowlist | `/allowlist add\|remove domain:` | empty |
+| domain blocklist | `/blocklist add\|remove domain:` | empty |
+| allowed invites | `/invites allow invite:` | empty |
+| `bypassRoleIds` / `bypassUserIds` | not yet exposed via slash command - see [ROADMAP.md](../ROADMAP.md) | empty |
+| `requireAllowlist`, `flagUnknownDomains`, `massMentionThreshold` | not yet exposed via slash command | `false` / `false` / `0` |
+
+`/antilink mode` accepts three user-facing words that map onto the
+underlying `EnforcementMode` (see [rules-engine.md](./rules-engine.md)):
+
+| Command value | Stored as | Effect |
+| ------------- | --------- | ------------------------------------------------------------------------------------------ |
+| `block` | `delete` | Deletes messages that score BLOCK or above |
+| `warn` | `warn` | Flags BLOCK/QUARANTINE-severity messages (audit log + mod-log embed) without deleting them |
+| `log` | `log` | Records everything, never deletes or times out |
+
+A fourth mode, `timeout`, exists in the data model (it additionally times
+out the author for a QUARANTINE-severity message) but isn't currently one
+of the three choices `/antilink mode` offers - see
+[ROADMAP.md](../ROADMAP.md).
+
+### Backing up / migrating a guild's configuration
+
+```
+/config export → a JSON file with the guild config + all three lists
+/config import → re-import that file (always scoped to the guild you run it in)
+```
+
+The same format is produced/consumed by the CLI:
+
+```bash
+antilink export-config --guild --db ./antilink.sqlite --out backup.json
+antilink import-config backup.json --db ./antilink.sqlite
+```
+
+## Local policy file (`antilink.config.json`)
+
+Used only by the CLI's `scan` and `test-url` commands (and `apps/example-bot`
+does **not** read this file - it uses per-guild config from the database).
+Create one with `antilink init`, which scaffolds a starter file. It maps
+directly onto `packages/core`'s `PolicyConfig`:
+
+```json
+{
+ "enabled": true,
+ "mode": "log",
+ "domainAllowlist": ["example.com"],
+ "domainBlocklist": ["bad-site.com"],
+ "knownPhishingDomains": [],
+ "inviteAllowlist": [],
+ "blockAllInvites": false,
+ "requireAllowlist": false,
+ "flagUnknownDomains": false,
+ "massMentionThreshold": 0,
+ "bypassRoleIds": [],
+ "bypassUserIds": [],
+ "channelRules": [{ "channelId": "123", "mode": "exempt" }],
+ "regexRules": [{ "id": "nitro-scam", "pattern": "free-nitro", "target": "url" }],
+ "urlShortenerDomains": []
+}
+```
+
+This file format is a superset of what's reachable through slash commands
+today - `channelRules`, `regexRules`, and `knownPhishingDomains` can only be
+set this way, not through the live bot yet. It's most useful for locally
+testing "would this message be flagged?" before wiring up rules in
+production.
+
+## `knownPhishingDomains` is never pre-populated
+
+Neither configuration surface ships with a bundled list of "known phishing
+domains." That field is always empty until you (or a feed you choose to
+subscribe to) fill it in. See [rules-engine.md](./rules-engine.md#detection-reasons)
+for why this is a deliberate choice.
diff --git a/docs/discord-setup.md b/docs/discord-setup.md
new file mode 100644
index 0000000..7f6be2a
--- /dev/null
+++ b/docs/discord-setup.md
@@ -0,0 +1,83 @@
+# Discord Setup
+
+## 1. Create the application
+
+1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) → **New Application**.
+2. Give it a name (this becomes the bot's display name unless you rename it later).
+
+## 2. Get your credentials
+
+- **Bot token**: **Bot** tab → **Reset Token**. This is `DISCORD_TOKEN`.
+ Treat it like a password - anyone with it fully controls your bot.
+- **Application ID**: **General Information** tab. This is `DISCORD_CLIENT_ID`,
+ needed only for registering slash commands.
+
+## 3. Enable the Message Content Intent
+
+Still on the **Bot** tab, under **Privileged Gateway Intents**, enable
+**Message Content Intent**. Without this, the bot receives messages with an
+empty `content` field and cannot scan anything - the CI-tested code path
+requires this intent to function at all.
+
+## 4. Required bot permissions
+
+Generate an invite URL from **OAuth2 → URL Generator**:
+
+- Scopes: `bot`, `applications.commands`
+- Bot permissions:
+
+| Permission | Why |
+| ---------------- | ----------------------------------------------------------------- |
+| View Channels | Required to see messages at all |
+| Send Messages | Required to reply to slash commands |
+| Manage Messages | Required to delete flagged messages (`delete`/`warn`... see note) |
+| Moderate Members | Required only if you use `timeout` mode |
+| Embed Links | Required to post mod-log embeds |
+
+> **Note on `warn` mode:** `warn` mode does not delete messages, so it
+> doesn't strictly require Manage Messages - but grant it anyway so you can
+> switch modes later without re-inviting the bot. If Manage Messages is
+> missing, the bot logs a warning and skips deletion rather than crashing
+> (see [threat-model.md](./threat-model.md)); it never assumes permissions
+> it doesn't have.
+
+## 5. Invite the bot
+
+Open the generated URL and select your test server. **Always test against a
+server you control** before running this in a real community.
+
+## 6. Register slash commands
+
+```bash
+pnpm --filter @antilink-guard/example-bot run register-commands
+```
+
+This registers: `/antilink`, `/allowlist`, `/blocklist`, `/invites`, `/logs`,
+`/testlink`, `/config`. Set `DISCORD_GUILD_ID` in your `.env` to register to
+one server only - guild commands update instantly, which is much faster to
+iterate on than global commands (which can take up to an hour to propagate
+to all servers).
+
+## 7. Verify
+
+```
+/antilink status
+/antilink enable
+/antilink mode block
+```
+
+Post a link from a non-admin test account in a non-exempt channel - it
+should be deleted within a second or two.
+
+### Nothing happens when I post a link?
+
+1. Message Content Intent enabled? (step 3)
+2. Bot has Manage Messages in that channel?
+3. Is the domain or invite on an allowlist?
+4. Testing with a server admin account and expecting it to be exempt? **It
+ won't be, by default.** Unlike some moderation bots, this one does not
+ automatically exempt Administrator/Manage Server permission holders -
+ the only exemptions are the explicit `bypassRoleIds`/`bypassUserIds` in
+ the guild's configuration (not yet settable via a slash command - see
+ [configuration.md](./configuration.md)). If you haven't configured any
+ bypasses, every account's messages are scanned, including yours.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..c3132ec
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,88 @@
+# Getting Started
+
+The fastest way to run AntiLink Guard OSS is [`apps/example-bot`](../apps/example-bot),
+a working bot assembled entirely from the published `@antilink-guard/*` packages.
+
+## 1. Prerequisites
+
+- [Node.js](https://nodejs.org) 20 or newer
+- [pnpm](https://pnpm.io) (`corepack enable` installs the pinned version)
+- A Discord account and a test server you can invite a bot to
+
+## 2. Clone and install
+
+```bash
+git clone https://github.com/timeout187/antilink-guard.git
+cd antilink-guard
+pnpm install
+pnpm run build
+```
+
+`pnpm run build` builds every package in the correct dependency order
+(`core` and `storage` first, then `discord-bot`/`cli`, then the apps) -
+required once before anything can run, since packages resolve each other
+through their built `dist/` output.
+
+## 3. Create a Discord application
+
+See [discord-setup.md](./discord-setup.md) for full detail. In short: create
+an application in the [Discord Developer Portal](https://discord.com/developers/applications),
+grab its bot token and Application ID, enable the **Message Content Intent**,
+and invite it to your test server.
+
+## 4. Configure and run
+
+```bash
+cp apps/example-bot/.env.example apps/example-bot/.env
+# edit apps/example-bot/.env: DISCORD_TOKEN, DISCORD_CLIENT_ID
+
+pnpm --filter @antilink-guard/example-bot run register-commands
+pnpm --filter @antilink-guard/example-bot run start
+```
+
+Full walkthrough, including Docker Compose: [`apps/example-bot/README.md`](../apps/example-bot/README.md).
+
+## 5. Turn on protection
+
+In your test server:
+
+```
+/antilink enable
+/antilink mode block
+```
+
+Post a link from a non-admin test account in a channel - it should be
+deleted. See [configuration.md](./configuration.md) for allowlists,
+blocklists, and invite rules, and [rules-engine.md](./rules-engine.md) for
+exactly how a message is scored.
+
+## 6. Optional: the local dashboard
+
+```bash
+cp apps/dashboard-lite/.env.example apps/dashboard-lite/.env
+pnpm --filter @antilink-guard/dashboard-lite run build
+pnpm --filter @antilink-guard/dashboard-lite run start
+```
+
+Opens on `http://localhost:4000`. It's local and unauthenticated by
+design - see its [README](../apps/dashboard-lite/README.md) before exposing
+it anywhere other than your own machine.
+
+## Trying the CLI without any Discord setup at all
+
+```bash
+pnpm --filter @antilink-guard/cli build
+node packages/cli/dist/index.js scan "check out https://bit.ly/free-nitro"
+node packages/cli/dist/index.js test-url https://xn--e1aybc.xn--p1ai
+node packages/cli/dist/index.js doctor
+```
+
+The detection and policy engine (`packages/core`) has no Discord dependency,
+so you can experiment with it entirely offline.
+
+## Where to go next
+
+- [`configuration.md`](./configuration.md) - the guild config model and how slash commands change it
+- [`rules-engine.md`](./rules-engine.md) - the detection pipeline in depth
+- [`self-hosting.md`](./self-hosting.md) - Docker, database choices, environment variables
+- [`api-reference.md`](./api-reference.md) - the package APIs, if you're building on top of this framework
diff --git a/docs/migration-from-old-antilink.md b/docs/migration-from-old-antilink.md
new file mode 100644
index 0000000..fc717e3
--- /dev/null
+++ b/docs/migration-from-old-antilink.md
@@ -0,0 +1,73 @@
+# Migrating from the old Anti-Links-Discord-Bot
+
+This repository was originally a single `index.js` file (still visible in
+this repo's git history, before the `feat!: begin AntiLink Guard OSS
+migration` commit). If you were running that version, here's how its
+behavior maps onto the new framework - **there is no automated migration
+script**, since the old bot had no database to migrate from; this is a
+manual mapping guide.
+
+## What the old bot did
+
+```js
+// old index.js, roughly
+const whitelistedChannels = ['1141168430620348517', ...]; // hardcoded
+const ignoredRoles = ['839237666545205248', ...]; // hardcoded
+
+client.on('messageCreate', (message) => {
+ if (message.content.match(/https?:\/\/\S+/gi)) {
+ if (!whitelistedChannels.includes(message.channel.id) &&
+ !hasIgnoredRole(message.member)) {
+ message.delete();
+ sendLogToWebhook(`Deleted message from ${message.author.tag}...`);
+ }
+ }
+});
+```
+
+It had: a hardcoded channel whitelist, a hardcoded role bypass list, basic
+`https?://` detection (no defanging/punycode/homoglyph handling), a
+Discord webhook for logging, and no persistence, no slash commands, and no
+per-guild configuration - it was hardcoded for one specific server.
+
+## What replaces each piece
+
+| Old bot | AntiLink Guard OSS |
+| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Hardcoded `whitelistedChannels` array | `/logs`... actually: per-channel exemptions aren't yet a slash command (see [ROADMAP.md](../ROADMAP.md)) - for now, set `channelRules: [{ channelId, mode: "exempt" }]` in a local policy file if using the CLI, or exempt via the database directly |
+| Hardcoded `ignoredRoles` array | `bypassRoleIds` on `GuildConfig` - not yet exposed via slash command either (see [ROADMAP.md](../ROADMAP.md)); set it directly via `/config import` with a hand-edited config bundle, or via `packages/storage`'s API |
+| `WEBHOOK_URL` + manual webhook POST | `/logs set-channel channel:<#channel>` - the bot posts a proper embed to a normal channel, no separate webhook to manage |
+| `message.content.match(/https?:\/\/\S+/gi)` | `packages/core`'s `extractLinks`/`evaluateMessage` - handles defanged links, punycode, homoglyphs, markdown links, and Discord invites, none of which the old regex caught |
+| Always deletes | Configurable via `/antilink mode ` - the old behavior is closest to `block` |
+| One server, no config UI | Per-guild configuration via slash commands, backed by a real database (SQLite/MySQL/PostgreSQL) |
+
+## Step by step
+
+1. Set up the new bot per [getting-started.md](./getting-started.md) - new
+ application, new token recommended (rotate rather than reuse the old
+ bot's token if you're replacing it in place).
+2. `/antilink enable`
+3. `/antilink mode block` (closest match to the old bot's always-delete behavior)
+4. Re-add your old bypass roles: for each role ID that was in the old
+ `ignoredRoles` array, you currently need to set `bypassRoleIds` directly
+ (see the table above) - a slash command for this is planned but not
+ shipped yet.
+5. Re-add your old whitelisted channels the same way, via `channelRules`
+ with `mode: "exempt"`.
+6. `/logs set-channel channel:#your-mod-log-channel` - replaces the old
+ `WEBHOOK_URL`. You can delete the old webhook once this is confirmed
+ working.
+7. Test: post a link from a non-exempt account - it should be deleted and
+ logged to your new mod-log channel.
+
+## Behavior differences to expect
+
+- **Broader detection.** Links the old regex missed (defanged, punycode,
+ homoglyph, markdown-wrapped) will now be caught. If you relied on people
+ being able to post `hxxps://` links to work around the old bot, that no
+ longer works.
+- **No implicit admin bypass in either version** - this hasn't changed:
+ neither the old bot nor the new one auto-exempts server admins. Bypass is
+ always explicit (role/user lists).
+- **Persistence.** Configuration now survives a bot restart/redeploy
+ without needing to edit and redeploy source code, once it's set.
diff --git a/docs/privacy.md b/docs/privacy.md
new file mode 100644
index 0000000..db2c232
--- /dev/null
+++ b/docs/privacy.md
@@ -0,0 +1,87 @@
+# Privacy
+
+AntiLink Guard OSS is self-hosted: **you run it, you control the database,
+and no data goes to any third party operated by this project** - there is
+no "phone home" telemetry anywhere in this codebase.
+
+## What is stored, and where
+
+Everything lives in the database you configure (SQLite by default; see
+[self-hosting.md](./self-hosting.md)). The models, defined in
+`packages/storage`:
+
+| Model | What it holds |
+| ----------------------------------- | ---------------------------------------------------------------------- |
+| `GuildConfig` | Your server's settings: enabled/mode/log channel/bypass lists |
+| `AllowlistEntry` / `BlocklistEntry` | A domain, who added it, when, and (blocklist only) an optional reason |
+| `InviteRule` | An allowed Discord invite code, who added it, when |
+| `AuditLogEntry` | **Metadata about a moderation decision** - see below |
+| `ScanResultRecord` | The outcome of a `/testlink` or CLI `test-url`/`scan` check |
+| `ModerationActionRecord` | The action actually taken (delete/timeout/warn/log) for an audit entry |
+
+## What an audit log entry contains - and what it deliberately does not
+
+```ts
+interface AuditLogEntry {
+ id: string;
+ guildId: string;
+ channelId: string;
+ userId: string;
+ normalizedUrl?: string; // the link that triggered the entry, normalized
+ hostname?: string;
+ verdict: Verdict;
+ reasons: DetectionReason[];
+ score: number;
+ action: ModerationActionType;
+ createdAt: Date;
+}
+```
+
+**There is no `content` or `message` field.** The full text of a scanned
+message is never written to the database - only the specific link that
+triggered a non-ALLOW verdict, plus the classification metadata needed to
+show a mod-log entry. This is enforced by the type itself, not just a
+convention: `AuditLogEntry` has no field capable of holding arbitrary
+message text. `packages/storage`'s shared adapter contract test suite
+(run against all four storage backends) includes an explicit assertion
+that audit entries never carry a `content` property.
+
+## What's sent to Discord
+
+- Slash command replies (ephemeral where the code marks them so)
+- Mod-log embeds, if you configure a log channel (`/logs set-channel`) -
+ these contain the same metadata as the audit log entry above: user
+ mention, channel mention, verdict, action, score, reasons, and the
+ triggering URL
+
+Deleted messages are removed via the normal Discord API `DELETE` message
+call; this project doesn't retain a copy of what was deleted.
+
+## `apps/dashboard-lite`
+
+Reads directly from your database and displays exactly what's described
+above - guild config, lists, and audit log entries. It has **no
+authentication** and is meant to run on `localhost` only. See its
+[README](../apps/dashboard-lite/README.md).
+
+## Third parties
+
+None, by this project. `packages/core`'s classification is entirely local
+(offline heuristics against your own configured lists) - no message
+content or URL is sent to an external API for scanning unless you build
+that integration yourself (see [ROADMAP.md](../ROADMAP.md) for a possible
+future plugin interface). The only network calls the bot itself makes are
+to the Discord API.
+
+## Your responsibilities as an operator
+
+You are the data controller for whatever your instance stores about your
+server's members. Standard good practice:
+
+- Only grant the bot the Discord permissions it needs (see
+ [discord-setup.md](./discord-setup.md)).
+- Don't expose `dashboard-lite` publicly.
+- If you're subject to a specific data-protection regime (GDPR, etc.),
+ treat `AuditLogEntry.userId` as personal data subject to your own
+ retention policy - this project doesn't implement automatic log
+ expiry yet (see [ROADMAP.md](../ROADMAP.md)).
diff --git a/docs/rules-engine.md b/docs/rules-engine.md
new file mode 100644
index 0000000..b5a931d
--- /dev/null
+++ b/docs/rules-engine.md
@@ -0,0 +1,160 @@
+# Rules Engine
+
+How `packages/core` turns a raw Discord message into a verdict and an
+action. This document describes the actual implementation - every
+function and constant named here exists in the code.
+
+## Pipeline overview
+
+```
+message text
+ │
+ ▼
+extractLinks() - find every URL/invite, de-obfuscating along the way
+ │
+ ▼
+classifyLink() - score each link against allow/blocklists, shorteners,
+ │ punycode/homoglyphs, and custom regex rules
+ ▼
+evaluateMessage() - apply bypasses, aggregate scores, resolve a verdict
+ │ and an action for the configured enforcement mode
+ ▼
+{ verdict, action, score, reasons, matchedLinks, bypassed }
+```
+
+## 1. Extraction (`extractLinks`)
+
+Finds, in order: markdown links, `discord.gg`/`discord.com/invite` invites,
+plain `https?://` URLs, and bare `www.` domains. Before matching, the text
+is de-obfuscated:
+
+- Zero-width characters (`U+00AD`, `U+180E`, `U+200B-200D`, `U+2060`,
+ `U+FEFF`) are stripped, so `example.com` (zero-width space mid-word) is
+ read as `example.com`.
+- `hxxp://` / `hxxps://` are reversed to `http://` / `https://`.
+- `example[.]com`, `example(.)com`, and `http[://]example.com` style
+ defanging is reversed.
+
+Each extracted link records whether it was found in a defanged/obfuscated
+form (`isDefanged`), whether it's a Discord invite (`isDiscordInvite`), and
+its normalized URL (lowercased host, tracking parameters like `utm_*`,
+`gclid`, `fbclid` stripped).
+
+## 2. Classification (`classifyLink`)
+
+Each link is checked, in this order, against the guild's policy:
+
+1. **Domain allowlist** - an exact or subdomain match short-circuits
+ everything else and allows the link (`ALLOWLIST_MATCH`).
+2. **Known-phishing domains** - `KNOWN_PHISHING_DOMAIN` (see note below on
+ why this list is empty by default).
+3. **Domain blocklist** - `BLOCKLIST_MATCH`.
+4. **URL shorteners** - a small built-in default list (`bit.ly`, `tinyurl.com`,
+ `t.co`, `is.gd`, `buff.ly`, `ow.ly`, `rebrand.ly`, `cutt.ly`, `shorturl.at`,
+ `rb.gy`, `tiny.cc`, `lnkd.in`, `v.gd`), overridable via
+ `urlShortenerDomains` - `URL_SHORTENER`.
+5. **Punycode hostnames** (any label starting `xn--`) - `PUNYCODE_SUSPICIOUS`.
+6. **Homoglyphs** - the (punycode-decoded) hostname mixes Latin letters with
+ Cyrillic or Greek ones (e.g. a Cyrillic "а" standing in for a Latin "a")
+ - `HOMOGLYPH_SUSPICIOUS`.
+7. If none of the above matched: `NOT_IN_ALLOWLIST` (only if
+ `requireAllowlist` is on) or `UNKNOWN_DOMAIN` (only if
+ `flagUnknownDomains` is on). Neither is applied by default, so an
+ unrecognized, otherwise-unremarkable domain is allowed by default.
+8. **Custom regex rules** always run last, against either the normalized
+ URL or the full message text (`target: "url" | "message"`) -
+ `REGEX_RULE_MATCH`.
+
+Discord invites follow a parallel path: an invite on the invite allowlist
+is `ALLOWLIST_MATCH`; otherwise it's tagged `DISCORD_INVITE`, and only
+scored if `blockAllInvites` is on.
+
+### Detection reasons
+
+| Reason | Meaning |
+| ------------------------ | ------------------------------------------------------------------- |
+| `ALLOWLIST_MATCH` | Domain or invite explicitly allowed - overrides everything |
+| `DISCORD_INVITE` | An invite link was found |
+| `BLOCKLIST_MATCH` | Domain is on this guild's blocklist |
+| `KNOWN_PHISHING_DOMAIN` | Domain is in `knownPhishingDomains` |
+| `URL_SHORTENER` | Domain is a known link shortener |
+| `PUNYCODE_SUSPICIOUS` | Hostname contains an `xn--` (punycode) label |
+| `HOMOGLYPH_SUSPICIOUS` | Hostname mixes Latin with Cyrillic/Greek letters |
+| `NOT_IN_ALLOWLIST` | `requireAllowlist` is on and the domain isn't listed |
+| `UNKNOWN_DOMAIN` | `flagUnknownDomains` is on and the domain is unrecognized |
+| `REGEX_RULE_MATCH` | A custom regex rule matched |
+| `MASS_MENTION_WITH_LINK` | The message both pinged many users/roles and contained a risky link |
+
+### Why `knownPhishingDomains` is always empty out of the box
+
+This project does not bundle, and does not claim to maintain, a
+threat-intelligence database of phishing domains. `knownPhishingDomains` is
+entirely guild-supplied (via the local policy file - see
+[configuration.md](./configuration.md)). If you want one, point it at a feed
+you trust; this framework won't pretend to be that feed itself.
+
+## 3. Scoring and policy (`evaluateMessage`)
+
+Each reason has a fixed severity score:
+
+| Reason | Score |
+| ---------------------------------------------- | -------------------------------- |
+| `ALLOWLIST_MATCH` | -1000 (forces ALLOW) |
+| `UNKNOWN_DOMAIN` | 5 |
+| `NOT_IN_ALLOWLIST` | 10 |
+| `URL_SHORTENER` | 15 |
+| `DISCORD_INVITE` | 20 |
+| `MASS_MENTION_WITH_LINK` | 25 |
+| `PUNYCODE_SUSPICIOUS` / `HOMOGLYPH_SUSPICIOUS` | 30 each |
+| `REGEX_RULE_MATCH` | 40 (or a rule-specific override) |
+| `BLOCKLIST_MATCH` | 50 |
+| `KNOWN_PHISHING_DOMAIN` | 100 |
+
+A message's score is the **maximum** across all its links (not a sum -
+one bad link is enough), plus a one-time `MASS_MENTION_WITH_LINK` bonus if
+the message both pings at least `massMentionThreshold` users/roles and
+contains at least one already-risky link.
+
+### Verdict thresholds
+
+| Score | Verdict |
+| ----- | ------------ |
+| < 1 | `ALLOW` |
+| ≥ 1 | `WARN` |
+| ≥ 30 | `BLOCK` |
+| ≥ 60 | `QUARANTINE` |
+
+### From verdict to action
+
+The guild's `mode` sets a ceiling on what the bot will actually do:
+
+| Verdict | `log` mode | `warn` mode | `delete` mode | `timeout` mode |
+| ------------ | ---------- | ----------- | ------------- | -------------------- |
+| `ALLOW` | none | none | none | none |
+| `WARN` | log | log | log | log |
+| `BLOCK` | log | **warn** | **delete** | delete |
+| `QUARANTINE` | log | **warn** | delete | **delete + timeout** |
+
+A `WARN` verdict is always just logged, regardless of mode - only `mode`
+determines how far the bot goes for `BLOCK`/`QUARANTINE` content.
+
+## 4. Bypasses (checked before any scanning happens)
+
+In order, `evaluateMessage` short-circuits to `ALLOW` (with `bypassed: true`)
+if:
+
+1. The policy is disabled (`enabled: false`).
+2. The author has a bypass role or is a bypass user.
+3. The channel has a rule with `mode: "exempt"`.
+
+Only after these checks does the message actually get scanned.
+
+## Trying it yourself
+
+```bash
+antilink scan "check out hxxps://free-nitro[.]ru/claim" --json
+antilink test-url https://xn--e1aybc.xn--p1ai
+```
+
+See [api-reference.md](./api-reference.md) for calling `evaluateMessage`
+directly from TypeScript.
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
new file mode 100644
index 0000000..2325973
--- /dev/null
+++ b/docs/self-hosting.md
@@ -0,0 +1,103 @@
+# Self-Hosting
+
+## Option A: bare Node.js
+
+```bash
+git clone https://github.com/timeout187/antilink-guard.git
+cd antilink-guard
+pnpm install
+pnpm run build
+
+cp apps/example-bot/.env.example apps/example-bot/.env
+# edit apps/example-bot/.env
+
+pnpm --filter @antilink-guard/example-bot run register-commands
+pnpm --filter @antilink-guard/example-bot run start
+```
+
+Run it under a process manager (`systemd`, `pm2`, etc.) for production so it
+restarts on crash or reboot. This project doesn't ship a systemd unit file
+or process-manager config - contributions welcome.
+
+## Option B: Docker Compose
+
+```bash
+cp apps/example-bot/.env.example apps/example-bot/.env
+# edit apps/example-bot/.env
+docker compose up --build -d
+```
+
+This builds the whole workspace inside the container (see
+[`apps/example-bot/Dockerfile`](../apps/example-bot/Dockerfile)) and runs
+the bot under a non-root user, with a named Docker volume
+(`antilink-data`) persisting the SQLite file across restarts and rebuilds.
+
+```bash
+docker compose logs -f # tail logs
+docker compose down # stop (data volume is preserved)
+```
+
+## Choosing a storage backend
+
+Set `DATABASE_DRIVER` in `apps/example-bot/.env`:
+
+| `DATABASE_DRIVER` | Also required | Notes |
+| ---------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------- |
+| `sqlite` (default) | `DATABASE_SQLITE_PATH` (optional, defaults to `./data/antilink.sqlite`) | No separate server. Directory is created automatically. |
+| `mysql` | `DATABASE_MYSQL_URL`, e.g. `mysql://user:pass@host:3306/antilink_guard` | Requires a running MySQL/MariaDB 8+ server |
+| `postgres` (or `postgresql`) | `DATABASE_POSTGRES_URL`, e.g. `postgres://user:pass@host:5432/antilink_guard` | Requires a running PostgreSQL server |
+
+The selection logic lives in
+[`apps/example-bot/src/create-storage.ts`](../apps/example-bot/src/create-storage.ts)
+if you're building your own bot on top of `@antilink-guard/discord-bot`
+instead of using the example app directly - it's a small, standalone
+function you can copy or import the underlying adapters yourself (see
+[api-reference.md](./api-reference.md)).
+
+All three adapters implement the exact same `StorageAdapter` interface and
+are validated by the same test suite, so switching drivers doesn't change
+bot behavior - only where the data lives.
+
+## Environment variables
+
+### `apps/example-bot`
+
+| Variable | Required | Description |
+| ----------------------- | ------------------------ | -------------------------------------------------------------------- |
+| `DISCORD_TOKEN` | Yes | Bot token |
+| `DISCORD_CLIENT_ID` | For command registration | Application ID |
+| `DISCORD_GUILD_ID` | No | Register commands to one server (instant) instead of globally (~1hr) |
+| `DATABASE_DRIVER` | No | `sqlite` (default) / `mysql` / `postgres` |
+| `DATABASE_SQLITE_PATH` | If using SQLite | Defaults to `./data/antilink.sqlite` |
+| `DATABASE_MYSQL_URL` | If using MySQL | Connection string |
+| `DATABASE_POSTGRES_URL` | If using PostgreSQL | Connection string |
+| `LOG_LEVEL` | No | `trace`\|`debug`\|`info`\|`warn`\|`error`\|`silent`, default `info` |
+
+### `apps/dashboard-lite`
+
+| Variable | Required | Description |
+| ---------------------- | -------- | -------------------------------------------------- |
+| `DATABASE_SQLITE_PATH` | No | Should point at the **same** database the bot uses |
+| `PORT` | No | Defaults to `4000` |
+
+## Updating
+
+```bash
+git pull
+pnpm install
+pnpm run build
+pnpm --filter @antilink-guard/example-bot run register-commands # only if commands changed - see CHANGELOG.md
+docker compose up --build -d # if using Docker
+```
+
+## Logging
+
+`packages/discord-bot` uses [pino](https://getpino.io), emitting structured
+JSON logs to stdout. For human-readable output while developing:
+
+```bash
+pnpm --filter @antilink-guard/example-bot run start | npx pino-pretty
+```
+
+Set `LOG_LEVEL=silent` to suppress logs entirely (used automatically in
+this repo's own test suite).
diff --git a/docs/threat-model.md b/docs/threat-model.md
new file mode 100644
index 0000000..5c84412
--- /dev/null
+++ b/docs/threat-model.md
@@ -0,0 +1,90 @@
+# Threat Model
+
+What AntiLink Guard OSS actually defends against, what it doesn't, and how
+it fails when it fails. Read this before relying on it as your only line of
+defense - see also [SECURITY.md](../SECURITY.md) for reporting a
+vulnerability in the project itself.
+
+## What this project detects
+
+Everything here is implemented in `packages/core` - see
+[rules-engine.md](./rules-engine.md) for the exact mechanics:
+
+- Links to domains you've explicitly blocklisted
+- Links to domains on a known-phishing list **you supply** (none is bundled)
+- Discord invite links, if you choose to restrict them
+- Common URL shorteners (a small built-in list, extendable)
+- Punycode (`xn--`) hostnames
+- Homoglyph domains mixing Latin with Cyrillic/Greek letters
+- Links obfuscated with `hxxp://`, `example[.]com`, or zero-width characters
+- Custom patterns you define via regex rules
+- A message that both pings many users/roles and contains a risky link
+
+## What this project does not detect
+
+- **Zero-day phishing domains not on any list.** There is no heuristic
+ content analysis of the destination page, no machine learning
+ classifier, and no bundled threat-intelligence feed. A brand-new
+ phishing domain that isn't on your blocklist and doesn't trip any other
+ reason above will be allowed.
+- **Direct messages.** The bot only processes `messageCreate` events for
+ guild (server) messages - it never reads or scans DMs.
+- **Non-text phishing vectors**: images, embedded QR codes, voice/video,
+ or links posted as plain text with no `http(s)://`/`www.` prefix and no
+ recognizable defanging pattern.
+- **Attachments.** File uploads aren't scanned.
+- **Edited messages.** The pipeline runs on `messageCreate` only - if a
+ message is edited _after_ being posted to add a malicious link, it is
+ not re-scanned (see [ROADMAP.md](../ROADMAP.md)).
+- **Sophisticated, targeted social engineering** that doesn't rely on a
+ link at all (e.g. a scammer asking a victim to screen-share).
+
+## Trust boundaries
+
+- **The bot trusts its own database.** Anything in your `AllowlistEntry`/
+ `BlocklistEntry`/`InviteRule` tables is trusted as configured. If your
+ database is compromised, so is your moderation policy - protect it like
+ any other credential store.
+- **The bot trusts Discord's permission model for command access**, not
+ its own. Every admin slash command (`/antilink`, `/allowlist`,
+ `/blocklist`, `/invites`, `/logs`, `/config`) is registered with
+ `default_member_permissions` requiring **Manage Server** - Discord
+ enforces this, the bot doesn't re-check it. `/testlink` is intentionally
+ open to everyone (read-only, low risk).
+- **The bot does not trust its own Discord permissions blindly.** Before
+ deleting a message or timing out a member, it checks discord.js's own
+ `message.deletable` / `member.moderatable` signals first (see
+ [rules-engine.md](./rules-engine.md) and `packages/discord-bot`'s
+ `moderation/enforce.ts`). If the bot lacks a permission, it logs a
+ warning and skips that action instead of crashing or silently pretending
+ to have succeeded.
+- **`/config import` is guild-scoped defensively.** Even if the uploaded
+ JSON file claims a different guild ID (from another server, or crafted by
+ hand), the bot always overwrites it with the guild the command was run
+ in - an admin can never accidentally or maliciously import data into a
+ server that isn't theirs.
+
+## Failure modes
+
+| Failure | Behavior |
+| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Bot lacks Manage Messages | Logs a warning, does not delete, still records an audit log entry |
+| Bot lacks Moderate Members / role hierarchy issue | Logs a warning, does not time out (message may still be deleted) |
+| Mod-log channel deleted or bot lacks access | Logs a warning, moderation still proceeds without the embed |
+| Malformed custom regex rule | The rule is silently skipped for that message rather than crashing the scan |
+| Database briefly unavailable | The message-processing error is caught and logged; that one message is not moderated, the bot keeps running |
+| A raid/spam wave | The moderation-action rate limiter (see `packages/discord-bot`'s `rate-limit.ts`) caps enforcement actions per guild so the bot doesn't hammer Discord's API into its own rate limit |
+
+## `apps/dashboard-lite` has no authentication
+
+This is a deliberate, documented limitation, not an oversight - see its
+[README](../apps/dashboard-lite/README.md) and
+[privacy.md](./privacy.md#appsdashboard-lite). Do not expose it beyond
+`localhost` without adding your own authentication layer in front of it.
+
+## Reporting an issue with this threat model
+
+If you find a gap here - a real attack this project should defend against
+but doesn't, or a case where it fails unsafely rather than safely - please
+report it per [SECURITY.md](../SECURITY.md) if it's exploitable, or open a
+regular issue if it's a documentation gap.
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0e5a073..4e708bd 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,3 @@
packages:
- - "packages/*"
- - "apps/*"
+ - 'packages/*'
+ - 'apps/*'