From fb9a40cb0f60180d7d3716135794b9282c6b6b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:29:58 +0000 Subject: [PATCH 1/9] docs: correct documentation URL to https://docs.antil.ink/ Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 4c35a15..a428819 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,7 +4,7 @@ contact_links: url: https://support.antil.ink/ about: Questions, setup help, and general discussion in the AntiLink support server. - name: 📖 Documentation - url: https://antil.ink/docs + url: https://docs.antil.ink/ about: Guides and configuration reference. - name: 🔒 Report a security vulnerability url: https://github.com/timeout187/Anti-Links-Discord-Bot/security/advisories/new From 098dbffe5388fe2550f00f1baf7e46332333fa2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:50:26 +0000 Subject: [PATCH 2/9] docs: reflect the hosted AntiLink platform and official links Sourced from the official docs (docs.antil.ink). Keeps this repo's feature list limited to what the open-source bot actually does (automatic link filtering) while accurately describing the separate, hosted AntiLink 2.0 product rather than implying it ships here. - Add 'This repo vs. hosted AntiLink' comparison and an 'Official links' table - Add an 'Add AntiLink 2.0 to Discord' link (invite.antil.ink) - Replace placeholder platform names with the real hosted product set (dashboard, Member Defense, Verify, Automod, Honeypot, Emergency Lockdown, custom bot, Free/Premium/AntiLink Premium), all marked as separate + hosted - Point the Commands 'Planned' note and FAQ at the hosted slash-command suite Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- CHANGELOG.md | 10 +++++++ README.md | 80 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36dd5e6..ff6ff98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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`). ### 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. diff --git a/README.md b/README.md index 61fdbd9..29da133 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,23 @@ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md) [![Code of Conduct](https://img.shields.io/badge/Contributor%20Covenant-2.1-purple.svg)](./CODE_OF_CONDUCT.md) -[Website][website] · [Documentation][docs] · [Discord][discord] · [Report a Bug][issues] · [Request a Feature][issues] +[Website][website] · [Documentation][docs] · [Dashboard][dashboard] · [Status][status] · [Discord][discord] · [Report a Bug][issues] + +**Want the fully-featured hosted bot instead of self-hosting?** +[![Add AntiLink to Discord](https://img.shields.io/badge/Add%20AntiLink%202.0-to%20Discord-5865F2?logo=discord&logoColor=white)][invite] --- -> **Note on scope.** This repository is the **community / open-source edition** of AntiLink. It contains the self-hostable Discord bot only. Commercial features, hosted infrastructure, billing, and proprietary detection systems are maintained separately and are **not** part of this repository. Anything on the [Roadmap](#roadmap) marked *Planned* does not exist here yet — it is stated as direction, not as a shipped feature. +> **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 - [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) @@ -41,11 +46,11 @@ ## 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 now maintained as the open-source foundation of the wider **AntiLink** moderation platform. +**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. -The broader AntiLink platform (hosted dashboard, analytics, AI-assisted detection, and verification) is a separate product line. See the [Roadmap](#roadmap) for how the open-source bot fits into that picture. +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)). ## Features @@ -59,6 +64,38 @@ Currently implemented in this repository: > 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. + ## Architecture The open-source bot follows a simple, single-process design: @@ -154,7 +191,7 @@ The community edition currently has **no slash commands** — it runs entirely a | ---------- | ------ | ----------- | | 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 | Slash-command administration (status, whitelist, bypass management) is on the [Roadmap](#roadmap) and does not exist yet. | +| `/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]. | ## Configuration @@ -176,9 +213,7 @@ _Coming soon — captures of a link removal and the webhook moderation log will ## Roadmap -Planned direction for the open-source bot and the wider AntiLink platform. **Unchecked items below are not shipped in this repository yet.** - -**Open-source bot** +Direction for **this open-source bot**. Unchecked items are not shipped here yet. - [x] Migrate configuration out of code into an environment-driven setup - [ ] Slash-command administration (`/antilink …`) @@ -187,15 +222,21 @@ Planned direction for the open-source bot and the wider AntiLink platform. **Unc - [ ] Structured logging + optional webhook embeds - [ ] Test suite and typed codebase -**AntiLink platform** *(separate, mostly closed-source products — listed for context)* +### The hosted AntiLink platform + +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]. -- [ ] **AntiLink Security** — advanced, hosted protection *(Planned)* -- [ ] **AntiLink Analytics** — moderation insights and dashboards *(Planned)* -- [ ] **AntiLink Billing** — subscriptions and licensing *(Planned)* -- [ ] **AntiLink AI** — AI-assisted detection *(Planned)* -- [ ] **AntiLink Verify** — member verification *(Planned)* +- 🛡️ **[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) -Have an idea? [Open a feature request][issues]. +Have an idea for the **open-source bot**? [Open a feature request][issues]. ## Contributing @@ -226,7 +267,7 @@ Distributed under the **MIT License**. See [LICENSE](./LICENSE) for details. ## Documentation -Full documentation lives at **[docs][docs]**. This README is the quickest path to a running self-hosted bot; the docs site covers advanced configuration, deployment, and the platform products. +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 @@ -242,8 +283,8 @@ Check that (1) the **Message Content Intent** is enabled, (2) the bot has **Mana **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 Security"?** -This repo is the self-hostable open-source bot. *AntiLink Security* is a separate, hosted, mostly closed-source product on the roadmap. Learn more at [antil.ink][website]. +**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. @@ -259,5 +300,8 @@ Built and maintained by the AntiLink community. Star ⭐ the repo if it's useful [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 From fa6ea352b208bf2645af6f9f2058e9864d250edd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:31:26 +0000 Subject: [PATCH 3/9] feat!: begin AntiLink Guard OSS migration - pnpm monorepo + core engine Starts the rebuild of this repo into "AntiLink Guard OSS", a self-hostable Discord anti-phishing/link-moderation framework. This is a ground-up rewrite: the old single-file index.js bot is replaced by a pnpm/TypeScript monorepo, though its core idea (detect links, exempt roles/channels, log actions) lives on in packages/core's policy engine. Foundation: - pnpm workspace (packages/*, apps/*), tsconfig.base.json (strict TS, ES2022) - ESLint flat config (typescript-eslint + prettier), Prettier, Vitest - Root package.json targets Node >=20, pnpm 10 packages/core (new): the reusable detection engine, fully unit tested (58 tests, 4 suites): - extraction/normalize.ts - zero-width char stripping, hxxp:// and example[.]com defang reversal, tracking-param stripping, hostname lowercasing - extraction/extract-links.ts - pulls URLs, markdown links, bare www. domains, and discord.gg/discord.com invite links out of message text, flagging which ones were defanged/obfuscated - classification/classify.ts + homoglyph.ts - scores a link against domain allow/blocklists, a known-phishing list, URL shorteners, punycode hostnames, mixed-script (Cyrillic/Greek vs Latin) homoglyphs, and user-supplied regex rules - policy/policy-engine.ts - ties it together: role/user bypass, per-channel exemptions or mode overrides, mass-mention escalation, and score thresholds that resolve to an ALLOW/WARN/BLOCK/QUARANTINE verdict plus a NONE/LOG/DELETE/TIMEOUT action for the configured enforcement mode No known-phishing domains are hardcoded - that list is entirely policy-supplied, so the package never claims a threat-intel database it doesn't have. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- .gitignore | 14 +- .npmrc | 2 + .prettierrc.json | 8 + eslint.config.js | 38 +- index.js | 61 - package-lock.json | 1434 ---------- package.json | 49 +- packages/core/package.json | 30 + packages/core/src/classification/classify.ts | 132 + packages/core/src/classification/homoglyph.ts | 23 + packages/core/src/classification/reasons.ts | 22 + packages/core/src/extraction/extract-links.ts | 73 + packages/core/src/extraction/normalize.ts | 88 + packages/core/src/index.ts | 7 + packages/core/src/policy/policy-engine.ts | 91 + packages/core/src/types/index.ts | 107 + packages/core/test/classify.test.ts | 143 + packages/core/test/extract-links.test.ts | 78 + packages/core/test/normalize.test.ts | 72 + packages/core/test/policy-engine.test.ts | 158 ++ packages/core/tsconfig.json | 8 + pnpm-lock.yaml | 2404 +++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 22 + vitest.config.ts | 10 + 25 files changed, 3544 insertions(+), 1533 deletions(-) create mode 100644 .npmrc create mode 100644 .prettierrc.json delete mode 100644 index.js delete mode 100644 package-lock.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/classification/classify.ts create mode 100644 packages/core/src/classification/homoglyph.ts create mode 100644 packages/core/src/classification/reasons.ts create mode 100644 packages/core/src/extraction/extract-links.ts create mode 100644 packages/core/src/extraction/normalize.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/policy/policy-engine.ts create mode 100644 packages/core/src/types/index.ts create mode 100644 packages/core/test/classify.test.ts create mode 100644 packages/core/test/extract-links.test.ts create mode 100644 packages/core/test/normalize.test.ts create mode 100644 packages/core/test/policy-engine.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index f4e6850..ae5b7e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,14 @@ node_modules/ .env.local .env.*.local *.token +*.pem +*.key + +# Local SQLite databases +*.sqlite +*.sqlite3 +*.db +data/ # Logs logs/ @@ -23,10 +31,12 @@ pnpm-debug.log* tmp/ coverage/ .nyc_output/ +.turbo/ # Build output dist/ build/ +*.tsbuildinfo # Editor / OS .vscode/* @@ -36,6 +46,6 @@ build/ .DS_Store Thumbs.db -# Optional package manager lockfiles from other managers (keep package-lock.json) +# This project uses pnpm — keep pnpm-lock.yaml, ignore other managers' lockfiles yarn.lock -pnpm-lock.yaml +package-lock.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4c2f52b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..9e91d39 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always" +} diff --git a/eslint.config.js b/eslint.config.js index 6e2d960..da90e36 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,28 +1,36 @@ -// ESLint flat config (ESLint v9+). -// A permissive baseline — tighten rules to match the project's style as the -// codebase is modernized. Requires: npm i -D eslint @eslint/js globals -const js = require('@eslint/js'); -const globals = require('globals'); +// ESLint flat config (ESLint v9+) for the AntiLink Guard OSS monorepo. +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import globals from 'globals'; -module.exports = [ +export default tseslint.config( + { + ignores: [ + '**/node_modules/', + '**/dist/', + '**/build/', + '**/coverage/', + '**/.turbo/', + '**/*.d.ts', + ], + }, js.configs.recommended, + ...tseslint.configs.recommended, { - files: ['**/*.js'], languageOptions: { ecmaVersion: 2023, - sourceType: 'commonjs', + sourceType: 'module', globals: { ...globals.node, }, }, rules: { - 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 'no-console': 'off', - eqeqeq: ['error', 'smart'], - 'prefer-const': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/consistent-type-imports': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', }, }, - { - ignores: ['node_modules/', 'dist/', 'build/', 'coverage/'], - }, -]; + eslintConfigPrettier, +); diff --git a/index.js b/index.js deleted file mode 100644 index 0d0b551..0000000 --- a/index.js +++ /dev/null @@ -1,61 +0,0 @@ -require('dotenv').config(); - -const { Client, Events, GatewayIntentBits, WebhookClient } = require('discord.js'); - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - ], -}); - -const token = process.env.DISCORD_TOKEN; -const webhookUrl = process.env.WEBHOOK_URL; - -// Comma-separated ID lists, e.g. WHITELISTED_CHANNEL_IDS=123,456 -const parseIdList = (value) => - (value ?? '') - .split(',') - .map((id) => id.trim()) - .filter(Boolean); - -const whitelistedChannels = parseIdList(process.env.WHITELISTED_CHANNEL_IDS); -const ignoredRoles = parseIdList(process.env.IGNORED_ROLE_IDS); - -// WEBHOOK_URL is optional; without it, deletions are simply not logged. -const webhook = webhookUrl ? new WebhookClient({ url: webhookUrl }) : null; - -const isWhitelistedChannel = (channelId) => whitelistedChannels.includes(channelId); - -// member is null for system/webhook messages, so default to "no ignored role" -const hasIgnoredRole = (member) => - member?.roles.cache.some((role) => ignoredRoles.includes(role.id)) ?? false; - -const sendLogToWebhook = async (content) => { - if (!webhook) return; - await webhook.send(content); -}; - -const linkPattern = /https?:\/\/\S+/i; - -client.once(Events.ClientReady, (readyClient) => { - console.log(`Logged in as ${readyClient.user.tag}. Watching for links.`); -}); - -client.on(Events.MessageCreate, async (message) => { - if (message.author.bot || !message.inGuild()) return; - if (!linkPattern.test(message.content)) return; - if (isWhitelistedChannel(message.channel.id) || hasIgnoredRole(message.member)) return; - - try { - await message.delete(); - await sendLogToWebhook( - `Deleted message from ${message.author.tag} in #${message.channel.name} containing a link.`, - ); - } catch (error) { - console.error('Failed to delete message or send log:', error); - } -}); - -client.login(token); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index b5ad86c..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1434 +0,0 @@ -{ - "name": "antilink", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "antilink", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "discord.js": "^14.26.4", - "dotenv": "^16.4.5" - }, - "devDependencies": { - "@eslint/js": "^9.39.4", - "eslint": "^9.39.4", - "globals": "^16.5.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@discordjs/builders": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", - "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", - "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.2.0", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.5", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.40", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", - "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/discord-api-types": { - "version": "0.38.49", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", - "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/discord.js": { - "version": "14.26.4", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", - "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/builders": "^1.14.1", - "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.1", - "@discordjs/util": "^1.2.0", - "@discordjs/ws": "^1.2.3", - "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT" - }, - "node_modules/magic-bytes.js": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", - "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json index 599dadc..211301a 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,14 @@ { - "name": "antilink", + "name": "antilink-guard", "version": "0.1.0", - "description": "AntiLink community edition — a Discord moderation bot that automatically removes unwanted links.", - "main": "index.js", - "scripts": { - "start": "node index.js", - "lint": "eslint ." + "private": true, + "description": "Open-source Discord anti-phishing and link moderation framework.", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=20" }, - "keywords": [ - "discord", - "discord-bot", - "moderation", - "anti-link", - "discord.js" - ], "homepage": "https://antil.ink", "repository": { "type": "git", @@ -22,17 +17,29 @@ "bugs": { "url": "https://github.com/timeout187/Anti-Links-Discord-Bot/issues" }, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "dependencies": { - "discord.js": "^14.26.4", - "dotenv": "^16.4.5" + "scripts": { + "build": "pnpm -r --filter \"./packages/*\" build", + "typecheck": "pnpm -r typecheck", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/node": "^22.10.2", "eslint": "^9.39.4", - "globals": "^16.5.0" + "eslint-config-prettier": "^9.1.0", + "globals": "^16.5.0", + "prettier": "^3.4.2", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "typescript-eslint": "^8.18.0", + "vitest": "^2.1.8" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] } } diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..3be62c4 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@antilink-guard/core", + "version": "0.1.0", + "description": "Reusable URL detection, classification, and policy engine for AntiLink Guard OSS.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/core/src/classification/classify.ts b/packages/core/src/classification/classify.ts new file mode 100644 index 0000000..da49180 --- /dev/null +++ b/packages/core/src/classification/classify.ts @@ -0,0 +1,132 @@ +import type { + DetectionReason, + ExtractedLink, + LinkClassification, + PolicyConfig, +} from '../types/index.js'; +import { DEFAULT_URL_SHORTENER_DOMAINS } from '../types/index.js'; +import { toUnicodeHostname } from '../extraction/normalize.js'; +import { hasMixedScriptConfusables, isPunycodeHostname } from './homoglyph.js'; +import { REASON_SEVERITY } from './reasons.js'; + +function domainMatches(hostname: string, entry: string): boolean { + const normalizedEntry = entry.toLowerCase().replace(/^\*\./, ''); + return hostname === normalizedEntry || hostname.endsWith(`.${normalizedEntry}`); +} + +function matchesAnyDomain(hostname: string, list: readonly string[] | undefined): boolean { + return (list ?? []).some((entry) => domainMatches(hostname, entry)); +} + +function matchesInviteAllowlist(link: ExtractedLink, list: readonly string[] | undefined): boolean { + if (!link.inviteCode) return false; + return (list ?? []).some((entry) => { + const trimmed = entry.trim().toLowerCase(); + const code = trimmed.replace( + /^https?:\/\/(www\.)?(discord\.gg|discord(?:app)?\.com\/invite)\//, + '', + ); + return code === link.inviteCode?.toLowerCase(); + }); +} + +export function classifyLink( + link: ExtractedLink, + policy: PolicyConfig, + rawMessageText: string, +): LinkClassification { + const reasons: DetectionReason[] = []; + let score = 0; + + if (link.isDiscordInvite) { + if (matchesInviteAllowlist(link, policy.inviteAllowlist)) { + return { link, reasons: ['ALLOWLIST_MATCH'], score: REASON_SEVERITY.ALLOWLIST_MATCH }; + } + reasons.push('DISCORD_INVITE'); + if (policy.blockAllInvites) { + score += REASON_SEVERITY.DISCORD_INVITE; + } + return applyRegexRules(link, policy, rawMessageText, reasons, score); + } + + if (matchesAnyDomain(link.hostname, policy.domainAllowlist)) { + return { link, reasons: ['ALLOWLIST_MATCH'], score: REASON_SEVERITY.ALLOWLIST_MATCH }; + } + + if (matchesAnyDomain(link.hostname, policy.knownPhishingDomains)) { + reasons.push('KNOWN_PHISHING_DOMAIN'); + score += REASON_SEVERITY.KNOWN_PHISHING_DOMAIN; + } + + if (matchesAnyDomain(link.hostname, policy.domainBlocklist)) { + reasons.push('BLOCKLIST_MATCH'); + score += REASON_SEVERITY.BLOCKLIST_MATCH; + } + + const shortenerDomains = policy.urlShortenerDomains ?? DEFAULT_URL_SHORTENER_DOMAINS; + if (matchesAnyDomain(link.hostname, shortenerDomains)) { + reasons.push('URL_SHORTENER'); + score += REASON_SEVERITY.URL_SHORTENER; + } + + if (isPunycodeHostname(link.hostname)) { + reasons.push('PUNYCODE_SUSPICIOUS'); + score += REASON_SEVERITY.PUNYCODE_SUSPICIOUS; + } + + const unicodeHostname = toUnicodeHostname(link.hostname); + if (hasMixedScriptConfusables(unicodeHostname)) { + reasons.push('HOMOGLYPH_SUSPICIOUS'); + score += REASON_SEVERITY.HOMOGLYPH_SUSPICIOUS; + } + + const hasHardMatch = reasons.some((r) => + ( + [ + 'KNOWN_PHISHING_DOMAIN', + 'BLOCKLIST_MATCH', + 'URL_SHORTENER', + 'PUNYCODE_SUSPICIOUS', + 'HOMOGLYPH_SUSPICIOUS', + ] as const + ).includes(r as never), + ); + + if (!hasHardMatch) { + if (policy.requireAllowlist) { + reasons.push('NOT_IN_ALLOWLIST'); + score += REASON_SEVERITY.NOT_IN_ALLOWLIST; + } else if (policy.flagUnknownDomains) { + reasons.push('UNKNOWN_DOMAIN'); + score += REASON_SEVERITY.UNKNOWN_DOMAIN; + } + } + + return applyRegexRules(link, policy, rawMessageText, reasons, score); +} + +function applyRegexRules( + link: ExtractedLink, + policy: PolicyConfig, + rawMessageText: string, + reasons: DetectionReason[], + score: number, +): LinkClassification { + let finalScore = score; + const finalReasons = [...reasons]; + + for (const rule of policy.regexRules ?? []) { + try { + const regex = new RegExp(rule.pattern, rule.flags ?? 'i'); + const target = rule.target === 'message' ? rawMessageText : link.normalizedUrl; + if (regex.test(target)) { + finalReasons.push('REGEX_RULE_MATCH'); + finalScore += rule.score ?? REASON_SEVERITY.REGEX_RULE_MATCH; + } + } catch { + // Invalid user-supplied regex is ignored rather than crashing the scan. + } + } + + return { link, reasons: finalReasons, score: finalScore }; +} diff --git a/packages/core/src/classification/homoglyph.ts b/packages/core/src/classification/homoglyph.ts new file mode 100644 index 0000000..0a862ed --- /dev/null +++ b/packages/core/src/classification/homoglyph.ts @@ -0,0 +1,23 @@ +function buildRange(startCodePoint: number, endCodePoint: number): RegExp { + const start = String.fromCharCode(startCodePoint); + const end = String.fromCharCode(endCodePoint); + return new RegExp(`[${start}-${end}]`); +} + +// Cyrillic (U+0400-04FF) and Greek (U+0370-03FF) blocks contain letters that are +// visually near-identical to Latin ones (e.g. Cyrillic 'а' vs Latin 'a'). Built +// from explicit code points (not literal glyphs) so the ranges stay unambiguous. +const CYRILLIC_RANGE = buildRange(0x0400, 0x04ff); +const GREEK_RANGE = buildRange(0x0370, 0x03ff); +const LATIN_LETTER_RANGE = /[a-zA-Z]/; + +export function isPunycodeHostname(hostname: string): boolean { + return hostname.split('.').some((label) => label.startsWith('xn--')); +} + +export function hasMixedScriptConfusables(unicodeHostname: string): boolean { + const hasLatin = LATIN_LETTER_RANGE.test(unicodeHostname); + const hasCyrillic = CYRILLIC_RANGE.test(unicodeHostname); + const hasGreek = GREEK_RANGE.test(unicodeHostname); + return hasLatin && (hasCyrillic || hasGreek); +} diff --git a/packages/core/src/classification/reasons.ts b/packages/core/src/classification/reasons.ts new file mode 100644 index 0000000..6d141ff --- /dev/null +++ b/packages/core/src/classification/reasons.ts @@ -0,0 +1,22 @@ +import type { DetectionReason } from '../types/index.js'; + +/** Score contribution per detection reason. Higher = more severe. */ +export const REASON_SEVERITY: Record = { + ALLOWLIST_MATCH: -1000, + UNKNOWN_DOMAIN: 5, + NOT_IN_ALLOWLIST: 10, + DISCORD_INVITE: 20, + URL_SHORTENER: 15, + PUNYCODE_SUSPICIOUS: 30, + HOMOGLYPH_SUSPICIOUS: 30, + REGEX_RULE_MATCH: 40, + BLOCKLIST_MATCH: 50, + KNOWN_PHISHING_DOMAIN: 100, + MASS_MENTION_WITH_LINK: 25, +}; + +export const VERDICT_THRESHOLDS = { + warn: 1, + block: 30, + quarantine: 60, +} as const; diff --git a/packages/core/src/extraction/extract-links.ts b/packages/core/src/extraction/extract-links.ts new file mode 100644 index 0000000..635acd5 --- /dev/null +++ b/packages/core/src/extraction/extract-links.ts @@ -0,0 +1,73 @@ +import type { ExtractedLink } from '../types/index.js'; +import { normalizeUrl, stripZeroWidthChars, undefangText } from './normalize.js'; + +const MARKDOWN_LINK_PATTERN = /\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gi; +const RAW_URL_PATTERN = /\bhttps?:\/\/[^\s<>"'\])]+/gi; +const BARE_WWW_PATTERN = /\bwww\.[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s<>"']*)?/gi; +const DISCORD_INVITE_PATTERN = + /\b(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi; + +function stripTrailingPunctuation(match: string): string { + return match.replace(/[.,;:!?)\]]+$/, ''); +} + +function wasObfuscated(originalText: string, matchedText: string): boolean { + return !originalText.toLowerCase().includes(matchedText.toLowerCase()); +} + +export function extractLinks(rawText: string): ExtractedLink[] { + const deobfuscated = undefangText(stripZeroWidthChars(rawText)); + const links: ExtractedLink[] = []; + const seen = new Set(); + + const addLink = (raw: string, isMarkdown: boolean): void => { + const cleaned = stripTrailingPunctuation(raw); + const inviteMatch = new RegExp(DISCORD_INVITE_PATTERN.source, 'i').exec(cleaned); + + if (inviteMatch?.[1]) { + const inviteCode = inviteMatch[1]; + const key = `invite:${inviteCode}`; + if (seen.has(key)) return; + seen.add(key); + links.push({ + raw: cleaned, + normalizedUrl: `https://discord.gg/${inviteCode}`, + hostname: 'discord.gg', + isMarkdown, + isDefanged: wasObfuscated(rawText, cleaned), + isDiscordInvite: true, + inviteCode, + }); + return; + } + + const normalized = normalizeUrl(cleaned); + if (!normalized) return; + if (seen.has(normalized.url)) return; + seen.add(normalized.url); + + links.push({ + raw: cleaned, + normalizedUrl: normalized.url, + hostname: normalized.hostname, + isMarkdown, + isDefanged: wasObfuscated(rawText, cleaned), + isDiscordInvite: false, + }); + }; + + for (const match of deobfuscated.matchAll(MARKDOWN_LINK_PATTERN)) { + if (match[2]) addLink(match[2], true); + } + for (const match of deobfuscated.matchAll(DISCORD_INVITE_PATTERN)) { + addLink(match[0], false); + } + for (const match of deobfuscated.matchAll(RAW_URL_PATTERN)) { + addLink(match[0], false); + } + for (const match of deobfuscated.matchAll(BARE_WWW_PATTERN)) { + addLink(match[0], false); + } + + return links; +} diff --git a/packages/core/src/extraction/normalize.ts b/packages/core/src/extraction/normalize.ts new file mode 100644 index 0000000..1b74c93 --- /dev/null +++ b/packages/core/src/extraction/normalize.ts @@ -0,0 +1,88 @@ +import { domainToUnicode } from 'node:url'; + +// Invisible characters used to break up scam URLs so naive scanners miss them: +// soft hyphen, Mongolian vowel separator, zero-width space/non-joiner/joiner, +// word joiner, and the zero-width no-break space (BOM). Built from explicit +// code points rather than literal characters so the pattern stays reviewable. +const ZERO_WIDTH_CODEPOINTS = [0x00ad, 0x180e, 0x200b, 0x200c, 0x200d, 0x2060, 0xfeff]; +const ZERO_WIDTH_PATTERN = new RegExp( + `[${ZERO_WIDTH_CODEPOINTS.map((cp) => String.fromCharCode(cp)).join('')}]`, + 'g', +); + +const TRACKING_PARAM_PREFIXES = ['utm_']; +const TRACKING_PARAM_EXACT = new Set([ + 'gclid', + 'fbclid', + 'msclkid', + 'mc_cid', + 'mc_eid', + 'igshid', + 'ref', + 'ref_src', + 'spm', +]); + +export function stripZeroWidthChars(text: string): string { + return text.replace(ZERO_WIDTH_PATTERN, ''); +} + +export function undefangText(text: string): string { + let result = text.replace(/h\s*x\s*x\s*p(s?)/gi, 'http$1'); + result = result.replace(/([a-z0-9-])\s?[[({]\s?\.\s?[\])}]\s?([a-z0-9-])/gi, '$1.$2'); + result = result.replace(/[[({]\s?:\s?\/\s?\/\s?[\])}]/gi, '://'); + return result; +} + +export function isTrackingParam(key: string): boolean { + const lower = key.toLowerCase(); + return ( + TRACKING_PARAM_EXACT.has(lower) || TRACKING_PARAM_PREFIXES.some((p) => lower.startsWith(p)) + ); +} + +export function stripTrackingParams(url: URL): URL { + const keysToDelete: string[] = []; + for (const key of url.searchParams.keys()) { + if (isTrackingParam(key)) keysToDelete.push(key); + } + for (const key of keysToDelete) url.searchParams.delete(key); + return url; +} + +export function toUnicodeHostname(hostname: string): string { + try { + return domainToUnicode(hostname); + } catch { + return hostname; + } +} + +export interface NormalizedUrl { + url: string; + hostname: string; +} + +export function normalizeUrl(rawUrl: string): NormalizedUrl | undefined { + let candidate = rawUrl.trim(); + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) { + candidate = `http://${candidate}`; + } + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return undefined; + } + + parsed.hostname = parsed.hostname.toLowerCase(); + stripTrackingParams(parsed); + + let serialized = parsed.toString(); + if (parsed.pathname === '/' && !parsed.search && !parsed.hash) { + serialized = serialized.replace(/\/$/, ''); + } + + return { url: serialized, hostname: parsed.hostname }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..64e92c8 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,7 @@ +export * from './types/index.js'; +export { extractLinks } from './extraction/extract-links.js'; +export { normalizeUrl, stripZeroWidthChars, undefangText } from './extraction/normalize.js'; +export { classifyLink } from './classification/classify.js'; +export { REASON_SEVERITY, VERDICT_THRESHOLDS } from './classification/reasons.js'; +export { isPunycodeHostname, hasMixedScriptConfusables } from './classification/homoglyph.js'; +export { evaluateMessage } from './policy/policy-engine.js'; diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts new file mode 100644 index 0000000..5791f25 --- /dev/null +++ b/packages/core/src/policy/policy-engine.ts @@ -0,0 +1,91 @@ +import type { + DetectionReason, + EnforcementMode, + LinkClassification, + ModerationActionType, + PolicyConfig, + ScanContext, + ScanResult, + Verdict, +} from '../types/index.js'; +import { extractLinks } from '../extraction/extract-links.js'; +import { classifyLink } from '../classification/classify.js'; +import { REASON_SEVERITY, VERDICT_THRESHOLDS } from '../classification/reasons.js'; + +function emptyResult(bypassed = false): ScanResult { + return { verdict: 'ALLOW', action: 'NONE', score: 0, reasons: [], matchedLinks: [], bypassed }; +} + +function isBypassed(context: ScanContext, policy: PolicyConfig): boolean { + const roleBypass = (context.authorRoleIds ?? []).some((roleId) => + (policy.bypassRoleIds ?? []).includes(roleId), + ); + const userBypass = (policy.bypassUserIds ?? []).includes(context.authorId); + return roleBypass || userBypass; +} + +function resolveChannelMode( + context: ScanContext, + policy: PolicyConfig, +): EnforcementMode | 'exempt' { + const rule = (policy.channelRules ?? []).find((r) => r.channelId === context.channelId); + return rule?.mode ?? policy.mode; +} + +function scoreToVerdict(score: number): Verdict { + if (score >= VERDICT_THRESHOLDS.quarantine) return 'QUARANTINE'; + if (score >= VERDICT_THRESHOLDS.block) return 'BLOCK'; + if (score >= VERDICT_THRESHOLDS.warn) return 'WARN'; + return 'ALLOW'; +} + +function verdictToAction(verdict: Verdict, mode: EnforcementMode): ModerationActionType { + if (verdict === 'ALLOW') return 'NONE'; + if (mode === 'log') return 'LOG'; + if (verdict === 'WARN') return 'LOG'; + if (mode === 'delete') return 'DELETE'; + // mode === 'timeout' + return verdict === 'QUARANTINE' ? 'TIMEOUT' : 'DELETE'; +} + +export function evaluateMessage( + rawText: string, + context: ScanContext, + policy: PolicyConfig, +): ScanResult { + if (!policy.enabled) return emptyResult(); + if (isBypassed(context, policy)) return emptyResult(true); + + const channelMode = resolveChannelMode(context, policy); + if (channelMode === 'exempt') return emptyResult(true); + + const links = extractLinks(rawText); + if (links.length === 0) return emptyResult(); + + const matchedLinks: LinkClassification[] = links.map((link) => + classifyLink(link, policy, rawText), + ); + + let score = Math.max(0, ...matchedLinks.map((m) => m.score)); + const reasons = new Set(); + for (const m of matchedLinks) for (const r of m.reasons) reasons.add(r); + + const threshold = policy.massMentionThreshold ?? 0; + const hasRiskyLink = matchedLinks.some((m) => m.score > 0); + if (threshold > 0 && (context.mentionCount ?? 0) >= threshold && hasRiskyLink) { + reasons.add('MASS_MENTION_WITH_LINK'); + score += REASON_SEVERITY.MASS_MENTION_WITH_LINK; + } + + const verdict = scoreToVerdict(score); + const action = verdictToAction(verdict, channelMode as EnforcementMode); + + return { + verdict, + action, + score, + reasons: Array.from(reasons), + matchedLinks, + bypassed: false, + }; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts new file mode 100644 index 0000000..e0cff7d --- /dev/null +++ b/packages/core/src/types/index.ts @@ -0,0 +1,107 @@ +export type Verdict = 'ALLOW' | 'WARN' | 'BLOCK' | 'QUARANTINE'; + +export type DetectionReason = + | 'DISCORD_INVITE' + | 'UNKNOWN_DOMAIN' + | 'KNOWN_PHISHING_DOMAIN' + | 'URL_SHORTENER' + | 'PUNYCODE_SUSPICIOUS' + | 'HOMOGLYPH_SUSPICIOUS' + | 'MASS_MENTION_WITH_LINK' + | 'BLOCKLIST_MATCH' + | 'ALLOWLIST_MATCH' + | 'REGEX_RULE_MATCH' + | 'NOT_IN_ALLOWLIST'; + +export type EnforcementMode = 'log' | 'delete' | 'timeout'; + +export type ModerationActionType = 'NONE' | 'LOG' | 'DELETE' | 'TIMEOUT'; + +export interface ExtractedLink { + /** The exact substring matched in the de-obfuscated text. */ + raw: string; + /** Normalized, absolute URL (scheme + lowercase host + stripped tracking params). */ + normalizedUrl: string; + hostname: string; + isMarkdown: boolean; + isDefanged: boolean; + isDiscordInvite: boolean; + inviteCode?: string; +} + +export interface LinkClassification { + link: ExtractedLink; + reasons: DetectionReason[]; + score: number; +} + +export interface RegexRule { + id: string; + pattern: string; + flags?: string; + /** Score contribution when this rule matches. Defaults to 40. */ + score?: number; + /** What the rule tests against. Defaults to 'url'. */ + target?: 'url' | 'message'; +} + +export interface ChannelRule { + channelId: string; + /** 'exempt' disables scanning entirely in this channel. */ + mode: 'exempt' | EnforcementMode; +} + +export interface PolicyConfig { + enabled: boolean; + mode: EnforcementMode; + channelRules?: ChannelRule[]; + bypassRoleIds?: string[]; + bypassUserIds?: string[]; + domainAllowlist?: string[]; + domainBlocklist?: string[]; + knownPhishingDomains?: string[]; + inviteAllowlist?: string[]; + blockAllInvites?: boolean; + regexRules?: RegexRule[]; + urlShortenerDomains?: string[]; + /** Only ALLOW links whose domain is explicitly allowlisted. Off by default. */ + requireAllowlist?: boolean; + /** Flag domains never seen on the allow/block lists with a low-severity WARN. */ + flagUnknownDomains?: boolean; + /** Number of user mentions in a message (with a link) that triggers MASS_MENTION_WITH_LINK. 0 disables. */ + massMentionThreshold?: number; +} + +export interface ScanContext { + guildId: string; + channelId: string; + authorId: string; + authorRoleIds?: string[]; + isBot?: boolean; + mentionCount?: number; +} + +export interface ScanResult { + verdict: Verdict; + action: ModerationActionType; + score: number; + reasons: DetectionReason[]; + matchedLinks: LinkClassification[]; + bypassed: boolean; +} + +export const DEFAULT_URL_SHORTENER_DOMAINS: readonly string[] = [ + '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', +]; diff --git a/packages/core/test/classify.test.ts b/packages/core/test/classify.test.ts new file mode 100644 index 0000000..347712e --- /dev/null +++ b/packages/core/test/classify.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { extractLinks } from '../src/extraction/extract-links.js'; +import { classifyLink } from '../src/classification/classify.js'; +import type { PolicyConfig } from '../src/types/index.js'; + +const basePolicy: PolicyConfig = { enabled: true, mode: 'delete' }; + +function classifyFirst(text: string, policy: PolicyConfig = basePolicy) { + const [link] = extractLinks(text); + if (!link) throw new Error('expected a link to be extracted'); + return classifyLink(link, policy, text); +} + +describe('classifyLink', () => { + it('allows a domain on the allowlist and short-circuits other checks', () => { + const result = classifyFirst('https://bit.ly/abc', { + ...basePolicy, + domainAllowlist: ['bit.ly'], + }); + expect(result.reasons).toEqual(['ALLOWLIST_MATCH']); + expect(result.score).toBeLessThan(0); + }); + + it('allows a subdomain of an allowlisted root domain', () => { + const result = classifyFirst('https://cdn.example.com/file', { + ...basePolicy, + domainAllowlist: ['example.com'], + }); + expect(result.reasons).toEqual(['ALLOWLIST_MATCH']); + }); + + it('flags a domain on the custom blocklist', () => { + const result = classifyFirst('https://bad-site.com/x', { + ...basePolicy, + domainBlocklist: ['bad-site.com'], + }); + expect(result.reasons).toContain('BLOCKLIST_MATCH'); + expect(result.score).toBeGreaterThan(0); + }); + + it('flags a domain on the known-phishing list with the highest severity', () => { + const result = classifyFirst('https://phish.example/x', { + ...basePolicy, + knownPhishingDomains: ['phish.example'], + }); + expect(result.reasons).toContain('KNOWN_PHISHING_DOMAIN'); + expect(result.score).toBeGreaterThanOrEqual(100); + }); + + it('flags a default URL shortener domain', () => { + const result = classifyFirst('https://bit.ly/abc'); + expect(result.reasons).toContain('URL_SHORTENER'); + }); + + it('flags a punycode hostname as suspicious', () => { + const result = classifyFirst('https://xn--e1aybc.xn--p1ai/login'); + expect(result.reasons).toContain('PUNYCODE_SUSPICIOUS'); + }); + + it('flags a mixed Latin/Cyrillic hostname as a homoglyph', () => { + const cyrillicA = String.fromCharCode(0x0430); + const result = classifyFirst(`https://p${cyrillicA}ypal.com/login`); + expect(result.reasons).toContain('HOMOGLYPH_SUSPICIOUS'); + }); + + it('does not flag an ordinary ASCII domain as a homoglyph', () => { + const result = classifyFirst('https://example.com', { + ...basePolicy, + flagUnknownDomains: true, + }); + expect(result.reasons).not.toContain('HOMOGLYPH_SUSPICIOUS'); + }); + + it('applies NOT_IN_ALLOWLIST when requireAllowlist is on and the domain is absent', () => { + const result = classifyFirst('https://example.com', { + ...basePolicy, + requireAllowlist: true, + }); + expect(result.reasons).toContain('NOT_IN_ALLOWLIST'); + }); + + it('applies UNKNOWN_DOMAIN when flagUnknownDomains is on', () => { + const result = classifyFirst('https://example.com', { + ...basePolicy, + flagUnknownDomains: true, + }); + expect(result.reasons).toContain('UNKNOWN_DOMAIN'); + }); + + it('stays clean (no reasons) for an unrecognized domain by default', () => { + const result = classifyFirst('https://example.com'); + expect(result.reasons).toHaveLength(0); + expect(result.score).toBe(0); + }); + + it('matches a custom regex rule against the URL', () => { + const result = classifyFirst('https://example.com/free-nitro-gift', { + ...basePolicy, + regexRules: [{ id: 'nitro-scam', pattern: 'free-nitro', target: 'url' }], + }); + expect(result.reasons).toContain('REGEX_RULE_MATCH'); + }); + + it('matches a custom regex rule against the raw message text', () => { + const result = classifyFirst('claim your free nitro now https://example.com', { + ...basePolicy, + regexRules: [{ id: 'nitro-word', pattern: 'free nitro', target: 'message' }], + }); + expect(result.reasons).toContain('REGEX_RULE_MATCH'); + }); + + it('ignores an invalid user-supplied regex instead of throwing', () => { + expect(() => + classifyFirst('https://example.com', { + ...basePolicy, + regexRules: [{ id: 'broken', pattern: '(unterminated' }], + }), + ).not.toThrow(); + }); + + it('allows a discord invite on the invite allowlist', () => { + const result = classifyFirst('https://discord.gg/abc123', { + ...basePolicy, + inviteAllowlist: ['abc123'], + }); + expect(result.reasons).toEqual(['ALLOWLIST_MATCH']); + }); + + it('blocks a discord invite not on the allowlist when blockAllInvites is set', () => { + const result = classifyFirst('https://discord.gg/xyz999', { + ...basePolicy, + blockAllInvites: true, + }); + expect(result.reasons).toContain('DISCORD_INVITE'); + expect(result.score).toBeGreaterThan(0); + }); + + it('tags but does not score a discord invite when blockAllInvites is off', () => { + const result = classifyFirst('https://discord.gg/xyz999', basePolicy); + expect(result.reasons).toContain('DISCORD_INVITE'); + expect(result.score).toBe(0); + }); +}); diff --git a/packages/core/test/extract-links.test.ts b/packages/core/test/extract-links.test.ts new file mode 100644 index 0000000..f549ea3 --- /dev/null +++ b/packages/core/test/extract-links.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { extractLinks } from '../src/extraction/extract-links.js'; + +describe('extractLinks', () => { + it('extracts a plain https URL', () => { + const links = extractLinks('check this out: https://example.com/page'); + expect(links).toHaveLength(1); + expect(links[0]?.normalizedUrl).toBe('https://example.com/page'); + expect(links[0]?.isDefanged).toBe(false); + }); + + it('extracts a bare www. domain without a scheme', () => { + const links = extractLinks('visit www.example.com today'); + expect(links).toHaveLength(1); + expect(links[0]?.hostname).toBe('www.example.com'); + }); + + it('extracts a markdown link and flags it', () => { + const links = extractLinks('[click here](https://example.com/offer)'); + expect(links).toHaveLength(1); + expect(links[0]?.isMarkdown).toBe(true); + expect(links[0]?.normalizedUrl).toBe('https://example.com/offer'); + }); + + it('extracts an hxxp:// defanged link and flags it as defanged', () => { + const links = extractLinks('free nitro hxxps://scam-site.com/claim'); + expect(links).toHaveLength(1); + expect(links[0]?.hostname).toBe('scam-site.com'); + expect(links[0]?.isDefanged).toBe(true); + }); + + it('extracts an example[.]com defanged domain and flags it as defanged', () => { + const links = extractLinks('go to hxxps://free-nitro[.]ru/claim now'); + expect(links).toHaveLength(1); + expect(links[0]?.hostname).toBe('free-nitro.ru'); + expect(links[0]?.isDefanged).toBe(true); + }); + + it('does not flag a plain link found verbatim in the original text as defanged', () => { + const links = extractLinks('https://example.com/page'); + expect(links[0]?.isDefanged).toBe(false); + }); + + it('extracts a discord.gg invite and captures the invite code', () => { + const links = extractLinks('join us at https://discord.gg/abc123'); + expect(links).toHaveLength(1); + expect(links[0]?.isDiscordInvite).toBe(true); + expect(links[0]?.inviteCode).toBe('abc123'); + }); + + it('extracts a discord.com/invite style invite', () => { + const links = extractLinks('https://discord.com/invite/xyz789'); + expect(links).toHaveLength(1); + expect(links[0]?.isDiscordInvite).toBe(true); + expect(links[0]?.inviteCode).toBe('xyz789'); + }); + + it('reassembles a link broken up by zero-width characters', () => { + const withZeroWidth = `https://exa${String.fromCharCode(0x200b)}mple.com`; + const links = extractLinks(`check ${withZeroWidth} out`); + expect(links).toHaveLength(1); + expect(links[0]?.hostname).toBe('example.com'); + }); + + it('deduplicates the same URL appearing twice', () => { + const links = extractLinks('https://example.com and again https://example.com'); + expect(links).toHaveLength(1); + }); + + it('returns an empty array for messages with no links', () => { + expect(extractLinks('just a normal message, no links here')).toHaveLength(0); + }); + + it('strips trailing punctuation from a matched URL', () => { + const links = extractLinks('Check this out: https://example.com/page.'); + expect(links[0]?.normalizedUrl).toBe('https://example.com/page'); + }); +}); diff --git a/packages/core/test/normalize.test.ts b/packages/core/test/normalize.test.ts new file mode 100644 index 0000000..e6025de --- /dev/null +++ b/packages/core/test/normalize.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeUrl, stripZeroWidthChars, undefangText } from '../src/extraction/normalize.js'; + +describe('stripZeroWidthChars', () => { + it('removes zero-width space characters injected mid-word', () => { + const withZeroWidth = 'exa' + String.fromCharCode(0x200b) + 'mple.com'; + expect(stripZeroWidthChars(withZeroWidth)).toBe('example.com'); + }); + + it('removes soft hyphen and BOM characters', () => { + const text = String.fromCharCode(0xfeff) + 'exam' + String.fromCharCode(0x00ad) + 'ple.com'; + expect(stripZeroWidthChars(text)).toBe('example.com'); + }); + + it('leaves normal text untouched', () => { + expect(stripZeroWidthChars('hello world')).toBe('hello world'); + }); +}); + +describe('undefangText', () => { + it('reverses hxxp:// to http://', () => { + expect(undefangText('visit hxxp://example.com now')).toContain('http://example.com'); + }); + + it('reverses hxxps:// to https://', () => { + expect(undefangText('visit hxxps://example.com now')).toContain('https://example.com'); + }); + + it('reverses example[.]com bracket-dot defanging', () => { + expect(undefangText('go to example[.]com/path')).toContain('example.com/path'); + }); + + it('reverses example(.)com paren-dot defanging', () => { + expect(undefangText('go to example(.)com')).toContain('example.com'); + }); + + it('reverses defanged scheme separator [://]', () => { + expect(undefangText('http[://]example.com')).toContain('http://example.com'); + }); + + it('does not corrupt ordinary sentences with parentheses', () => { + expect(undefangText('see the notes (draft version)')).toBe('see the notes (draft version)'); + }); +}); + +describe('normalizeUrl', () => { + it('lowercases the hostname', () => { + const result = normalizeUrl('https://EXAMPLE.com/Path'); + expect(result?.hostname).toBe('example.com'); + }); + + it('prepends http:// to scheme-less input', () => { + const result = normalizeUrl('example.com'); + expect(result?.url.startsWith('http://')).toBe(true); + }); + + it('strips common tracking parameters', () => { + const result = normalizeUrl('https://example.com/?utm_source=x&gclid=y&id=keep'); + expect(result?.url).not.toContain('utm_source'); + expect(result?.url).not.toContain('gclid'); + expect(result?.url).toContain('id=keep'); + }); + + it('strips a bare trailing slash on the root path', () => { + const result = normalizeUrl('https://example.com/'); + expect(result?.url).toBe('https://example.com'); + }); + + it('returns undefined for garbage input', () => { + expect(normalizeUrl('not a url at all ://')).toBeUndefined(); + }); +}); diff --git a/packages/core/test/policy-engine.test.ts b/packages/core/test/policy-engine.test.ts new file mode 100644 index 0000000..c4a34e1 --- /dev/null +++ b/packages/core/test/policy-engine.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateMessage } from '../src/policy/policy-engine.js'; +import type { PolicyConfig, ScanContext } from '../src/types/index.js'; + +const context: ScanContext = { + guildId: 'guild-1', + channelId: 'channel-1', + authorId: 'user-1', + authorRoleIds: [], +}; + +const basePolicy: PolicyConfig = { enabled: true, mode: 'delete' }; + +describe('evaluateMessage', () => { + it('allows a message with no links', () => { + const result = evaluateMessage('hello world', context, basePolicy); + expect(result.verdict).toBe('ALLOW'); + expect(result.action).toBe('NONE'); + expect(result.bypassed).toBe(false); + }); + + it('does nothing at all when the policy is disabled', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + enabled: false, + domainBlocklist: ['bad-site.com'], + }); + expect(result.verdict).toBe('ALLOW'); + expect(result.reasons).toHaveLength(0); + }); + + it('bypasses scanning for a whitelisted role', () => { + const result = evaluateMessage( + 'https://bad-site.com', + { ...context, authorRoleIds: ['mod-role'] }, + { ...basePolicy, bypassRoleIds: ['mod-role'], domainBlocklist: ['bad-site.com'] }, + ); + expect(result.bypassed).toBe(true); + expect(result.verdict).toBe('ALLOW'); + }); + + it('bypasses scanning for a whitelisted user', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + bypassUserIds: ['user-1'], + domainBlocklist: ['bad-site.com'], + }); + expect(result.bypassed).toBe(true); + }); + + it('exempts a channel configured with mode "exempt"', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + channelRules: [{ channelId: 'channel-1', mode: 'exempt' }], + domainBlocklist: ['bad-site.com'], + }); + expect(result.bypassed).toBe(true); + }); + + it('returns WARN for a low-severity unknown domain when flagUnknownDomains is on', () => { + const result = evaluateMessage('https://example.com', context, { + ...basePolicy, + flagUnknownDomains: true, + }); + expect(result.verdict).toBe('WARN'); + expect(result.action).toBe('LOG'); + }); + + it('returns BLOCK and a DELETE action for a blocklisted domain in delete mode', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + domainBlocklist: ['bad-site.com'], + }); + expect(result.verdict).toBe('BLOCK'); + expect(result.action).toBe('DELETE'); + }); + + it('returns QUARANTINE for a known-phishing domain', () => { + const result = evaluateMessage('https://phish.example', context, { + ...basePolicy, + knownPhishingDomains: ['phish.example'], + }); + expect(result.verdict).toBe('QUARANTINE'); + }); + + it('caps the action at LOG when mode is "log", even for a QUARANTINE verdict', () => { + const result = evaluateMessage('https://phish.example', context, { + ...basePolicy, + mode: 'log', + knownPhishingDomains: ['phish.example'], + }); + expect(result.verdict).toBe('QUARANTINE'); + expect(result.action).toBe('LOG'); + }); + + it('escalates a QUARANTINE verdict to TIMEOUT only in timeout mode', () => { + const result = evaluateMessage('https://phish.example', context, { + ...basePolicy, + mode: 'timeout', + knownPhishingDomains: ['phish.example'], + }); + expect(result.action).toBe('TIMEOUT'); + }); + + it('only deletes (does not time out) a plain BLOCK verdict in timeout mode', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + mode: 'timeout', + domainBlocklist: ['bad-site.com'], + }); + expect(result.verdict).toBe('BLOCK'); + expect(result.action).toBe('DELETE'); + }); + + it('adds MASS_MENTION_WITH_LINK when mention count meets the threshold alongside a risky link', () => { + const result = evaluateMessage( + 'https://bad-site.com', + { ...context, mentionCount: 10 }, + { + ...basePolicy, + domainBlocklist: ['bad-site.com'], + massMentionThreshold: 5, + }, + ); + expect(result.reasons).toContain('MASS_MENTION_WITH_LINK'); + }); + + it('does not add MASS_MENTION_WITH_LINK when there is no risky link', () => { + const result = evaluateMessage( + 'https://example.com', + { ...context, mentionCount: 10 }, + { + ...basePolicy, + massMentionThreshold: 5, + }, + ); + expect(result.reasons).not.toContain('MASS_MENTION_WITH_LINK'); + }); + + it('allows a message whose only link is on the domain allowlist', () => { + const result = evaluateMessage('https://example.com', context, { + ...basePolicy, + domainAllowlist: ['example.com'], + requireAllowlist: true, + }); + expect(result.verdict).toBe('ALLOW'); + }); + + it('uses a per-channel mode override instead of the guild-wide mode', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + mode: 'log', + channelRules: [{ channelId: 'channel-1', mode: 'delete' }], + domainBlocklist: ['bad-site.com'], + }); + expect(result.action).toBe('DELETE'); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..da2867d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2404 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + eslint: + specifier: ^9.39.4 + version: 9.39.4 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@9.39.4) + globals: + specifier: ^16.5.0 + version: 16.5.0 + prettier: + specifier: ^3.4.2 + version: 3.9.4 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.18.0 + version: 8.63.0(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.0) + + packages/core: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.0) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.63.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.20.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.2(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + object-assign@4.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.16): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.16 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.4: {} + + punycode@2.3.1: {} + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.16)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.16) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.16 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.63.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@2.1.9(@types/node@22.20.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.20.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.20.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.0) + vite-node: 2.1.9(@types/node@22.20.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..0e5a073 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "apps/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..492f0e1 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6e2ceec --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], + coverage: { + reporter: ['text', 'lcov'], + }, + }, +}); From 076d5b7c895c044fa4ac9b5f2f6b84cf46d59ba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:40:04 +0000 Subject: [PATCH 4/9] feat: add packages/storage with memory, SQLite, MySQL, and Postgres adapters The persistence layer for AntiLink Guard OSS. All four adapters implement the same StorageAdapter interface and are validated against a shared behavioral contract test suite (94 tests total, 12 per adapter): - MemoryStorageAdapter - in-process, no dependencies, used by default in tests and the CLI - SqliteStorageAdapter (better-sqlite3) - the default for self-hosting; zero external services required - PostgresStorageAdapter (pg) - contract-tested against a live local Postgres instance in this session, not just written and assumed correct - MysqlStorageAdapter (mysql2) - same schema/logic shape as the others; its contract tests are honestly skipped (not faked passing) unless MYSQL_TEST_URL points at a reachable server Data models: GuildConfig, AllowlistEntry, BlocklistEntry, InviteRule, AuditLogEntry, ScanResultRecord, ModerationActionRecord. AuditLogEntry intentionally has no message-content field - only the metadata needed for a moderation log (user, channel, normalized URL, verdict, reasons, score, action, timestamp). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- package.json | 2 +- packages/storage/package.json | 38 ++ packages/storage/src/adapters/memory.ts | 113 +++++ packages/storage/src/adapters/mysql.ts | 404 ++++++++++++++++++ packages/storage/src/adapters/postgres.ts | 412 ++++++++++++++++++ packages/storage/src/adapters/sqlite.ts | 388 +++++++++++++++++ packages/storage/src/index.ts | 8 + packages/storage/src/types.ts | 134 ++++++ packages/storage/test/contract.ts | 254 +++++++++++ packages/storage/test/memory.test.ts | 4 + packages/storage/test/mysql.test.ts | 13 + packages/storage/test/postgres.test.ts | 11 + packages/storage/test/sqlite.test.ts | 7 + packages/storage/tsconfig.json | 8 + pnpm-lock.yaml | 499 ++++++++++++++++++++++ 15 files changed, 2294 insertions(+), 1 deletion(-) create mode 100644 packages/storage/package.json create mode 100644 packages/storage/src/adapters/memory.ts create mode 100644 packages/storage/src/adapters/mysql.ts create mode 100644 packages/storage/src/adapters/postgres.ts create mode 100644 packages/storage/src/adapters/sqlite.ts create mode 100644 packages/storage/src/index.ts create mode 100644 packages/storage/src/types.ts create mode 100644 packages/storage/test/contract.ts create mode 100644 packages/storage/test/memory.test.ts create mode 100644 packages/storage/test/mysql.test.ts create mode 100644 packages/storage/test/postgres.test.ts create mode 100644 packages/storage/test/sqlite.test.ts create mode 100644 packages/storage/tsconfig.json diff --git a/package.json b/package.json index 211301a..1a87a68 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,6 @@ "vitest": "^2.1.8" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "onlyBuiltDependencies": ["esbuild", "better-sqlite3"] } } diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 0000000..54ec379 --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,38 @@ +{ + "name": "@antilink-guard/storage", + "version": "0.1.0", + "description": "Storage adapters (memory, SQLite, MySQL, PostgreSQL) for AntiLink Guard OSS.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@antilink-guard/core": "workspace:*", + "better-sqlite3": "^11.8.1", + "mysql2": "^3.12.0", + "pg": "^8.13.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.2", + "@types/pg": "^8.11.10", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/storage/src/adapters/memory.ts b/packages/storage/src/adapters/memory.ts new file mode 100644 index 0000000..db4d4bd --- /dev/null +++ b/packages/storage/src/adapters/memory.ts @@ -0,0 +1,113 @@ +import type { + AllowlistEntry, + AuditLogEntry, + BlocklistEntry, + GuildConfig, + InviteRule, + ListOptions, + ModerationActionRecord, + ScanResultRecord, + StorageAdapter, +} from '../types.js'; + +function sortByCreatedAtDesc(items: T[]): T[] { + return [...items].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); +} + +function applyLimit(items: T[], options?: ListOptions): T[] { + return options?.limit ? items.slice(0, options.limit) : items; +} + +export class MemoryStorageAdapter implements StorageAdapter { + private guildConfigs = new Map(); + private allowlistEntries: AllowlistEntry[] = []; + private blocklistEntries: BlocklistEntry[] = []; + private inviteRules: InviteRule[] = []; + private auditLogEntries: AuditLogEntry[] = []; + private scanResults: ScanResultRecord[] = []; + private moderationActions: ModerationActionRecord[] = []; + + async init(): Promise {} + + async close(): Promise {} + + async getGuildConfig(guildId: string): Promise { + return this.guildConfigs.get(guildId); + } + + async upsertGuildConfig(config: GuildConfig): Promise { + this.guildConfigs.set(config.guildId, { ...config }); + } + + async listAllowlistEntries(guildId: string): Promise { + return this.allowlistEntries.filter((e) => e.guildId === guildId); + } + + async addAllowlistEntry(entry: AllowlistEntry): Promise { + this.allowlistEntries.push({ ...entry }); + } + + async removeAllowlistEntry(guildId: string, id: string): Promise { + this.allowlistEntries = this.allowlistEntries.filter( + (e) => !(e.guildId === guildId && e.id === id), + ); + } + + async listBlocklistEntries(guildId: string): Promise { + return this.blocklistEntries.filter((e) => e.guildId === guildId); + } + + async addBlocklistEntry(entry: BlocklistEntry): Promise { + this.blocklistEntries.push({ ...entry }); + } + + async removeBlocklistEntry(guildId: string, id: string): Promise { + this.blocklistEntries = this.blocklistEntries.filter( + (e) => !(e.guildId === guildId && e.id === id), + ); + } + + async listInviteRules(guildId: string): Promise { + return this.inviteRules.filter((e) => e.guildId === guildId); + } + + async addInviteRule(rule: InviteRule): Promise { + this.inviteRules.push({ ...rule }); + } + + async removeInviteRule(guildId: string, id: string): Promise { + this.inviteRules = this.inviteRules.filter((e) => !(e.guildId === guildId && e.id === id)); + } + + async addAuditLogEntry(entry: AuditLogEntry): Promise { + this.auditLogEntries.push({ ...entry }); + } + + async listAuditLogEntries(guildId: string, options?: ListOptions): Promise { + const entries = sortByCreatedAtDesc(this.auditLogEntries.filter((e) => e.guildId === guildId)); + return applyLimit(entries, options); + } + + async addScanResult(result: ScanResultRecord): Promise { + this.scanResults.push({ ...result }); + } + + async listScanResults(guildId: string, options?: ListOptions): Promise { + const entries = sortByCreatedAtDesc(this.scanResults.filter((e) => e.guildId === guildId)); + return applyLimit(entries, options); + } + + async addModerationAction(action: ModerationActionRecord): Promise { + this.moderationActions.push({ ...action }); + } + + async listModerationActions( + guildId: string, + options?: ListOptions, + ): Promise { + const entries = sortByCreatedAtDesc( + this.moderationActions.filter((e) => e.guildId === guildId), + ); + return applyLimit(entries, options); + } +} diff --git a/packages/storage/src/adapters/mysql.ts b/packages/storage/src/adapters/mysql.ts new file mode 100644 index 0000000..9b76e05 --- /dev/null +++ b/packages/storage/src/adapters/mysql.ts @@ -0,0 +1,404 @@ +import mysql from 'mysql2/promise'; +import type { + AllowlistEntry, + AuditLogEntry, + BlocklistEntry, + GuildConfig, + InviteRule, + ListOptions, + ModerationActionRecord, + ScanResultRecord, + StorageAdapter, +} from '../types.js'; + +const SCHEMA_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS guild_configs ( + guild_id VARCHAR(64) PRIMARY KEY, + enabled TINYINT(1) NOT NULL, + mode VARCHAR(16) NOT NULL, + log_channel_id VARCHAR(64), + bypass_role_ids TEXT NOT NULL, + bypass_user_ids TEXT NOT NULL, + block_all_invites TINYINT(1) NOT NULL, + require_allowlist TINYINT(1) NOT NULL, + flag_unknown_domains TINYINT(1) NOT NULL, + mass_mention_threshold INT NOT NULL, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS allowlist_entries ( + id VARCHAR(64) PRIMARY KEY, + guild_id VARCHAR(64) NOT NULL, + domain VARCHAR(255) NOT NULL, + added_by VARCHAR(64) NOT NULL, + created_at VARCHAR(32) NOT NULL, + INDEX idx_allowlist_guild (guild_id) + )`, + `CREATE TABLE IF NOT EXISTS blocklist_entries ( + id VARCHAR(64) PRIMARY KEY, + guild_id VARCHAR(64) NOT NULL, + domain VARCHAR(255) NOT NULL, + added_by VARCHAR(64) NOT NULL, + reason VARCHAR(500), + created_at VARCHAR(32) NOT NULL, + INDEX idx_blocklist_guild (guild_id) + )`, + `CREATE TABLE IF NOT EXISTS invite_rules ( + id VARCHAR(64) PRIMARY KEY, + guild_id VARCHAR(64) NOT NULL, + invite_code VARCHAR(64) NOT NULL, + added_by VARCHAR(64) NOT NULL, + created_at VARCHAR(32) NOT NULL, + INDEX idx_invite_rules_guild (guild_id) + )`, + `CREATE TABLE IF NOT EXISTS audit_log_entries ( + id VARCHAR(64) PRIMARY KEY, + guild_id VARCHAR(64) NOT NULL, + channel_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + normalized_url TEXT, + hostname VARCHAR(255), + verdict VARCHAR(16) NOT NULL, + reasons TEXT NOT NULL, + score INT NOT NULL, + action VARCHAR(16) NOT NULL, + created_at VARCHAR(32) NOT NULL, + INDEX idx_audit_log_guild (guild_id, created_at) + )`, + `CREATE TABLE IF NOT EXISTS scan_results ( + id VARCHAR(64) PRIMARY KEY, + guild_id VARCHAR(64), + input TEXT NOT NULL, + normalized_url TEXT, + verdict VARCHAR(16) NOT NULL, + reasons TEXT NOT NULL, + score INT NOT NULL, + created_at VARCHAR(32) NOT NULL, + INDEX idx_scan_results_guild (guild_id, created_at) + )`, + `CREATE TABLE IF NOT EXISTS moderation_actions ( + id VARCHAR(64) PRIMARY KEY, + guild_id VARCHAR(64) NOT NULL, + audit_log_entry_id VARCHAR(64), + user_id VARCHAR(64) NOT NULL, + channel_id VARCHAR(64) NOT NULL, + action_type VARCHAR(16) NOT NULL, + reason VARCHAR(500) NOT NULL, + created_at VARCHAR(32) NOT NULL, + INDEX idx_moderation_actions_guild (guild_id, created_at) + )`, +]; + +function toGuildConfig(row: Record): GuildConfig { + return { + guildId: row.guild_id as string, + enabled: Boolean(row.enabled), + mode: row.mode as GuildConfig['mode'], + logChannelId: (row.log_channel_id as string | null) ?? undefined, + bypassRoleIds: JSON.parse(row.bypass_role_ids as string) as string[], + bypassUserIds: JSON.parse(row.bypass_user_ids as string) as string[], + blockAllInvites: Boolean(row.block_all_invites), + requireAllowlist: Boolean(row.require_allowlist), + flagUnknownDomains: Boolean(row.flag_unknown_domains), + massMentionThreshold: Number(row.mass_mention_threshold), + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +function toAllowlistEntry(row: Record): AllowlistEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + domain: row.domain as string, + addedBy: row.added_by as string, + createdAt: new Date(row.created_at as string), + }; +} + +function toBlocklistEntry(row: Record): BlocklistEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + domain: row.domain as string, + addedBy: row.added_by as string, + reason: (row.reason as string | null) ?? undefined, + createdAt: new Date(row.created_at as string), + }; +} + +function toInviteRule(row: Record): InviteRule { + return { + id: row.id as string, + guildId: row.guild_id as string, + inviteCode: row.invite_code as string, + addedBy: row.added_by as string, + createdAt: new Date(row.created_at as string), + }; +} + +function toAuditLogEntry(row: Record): AuditLogEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + channelId: row.channel_id as string, + userId: row.user_id as string, + normalizedUrl: (row.normalized_url as string | null) ?? undefined, + hostname: (row.hostname as string | null) ?? undefined, + verdict: row.verdict as AuditLogEntry['verdict'], + reasons: JSON.parse(row.reasons as string) as AuditLogEntry['reasons'], + score: Number(row.score), + action: row.action as AuditLogEntry['action'], + createdAt: new Date(row.created_at as string), + }; +} + +function toScanResultRecord(row: Record): ScanResultRecord { + return { + id: row.id as string, + guildId: (row.guild_id as string | null) ?? undefined, + input: row.input as string, + normalizedUrl: (row.normalized_url as string | null) ?? undefined, + verdict: row.verdict as ScanResultRecord['verdict'], + reasons: JSON.parse(row.reasons as string) as ScanResultRecord['reasons'], + score: Number(row.score), + createdAt: new Date(row.created_at as string), + }; +} + +function toModerationActionRecord(row: Record): ModerationActionRecord { + return { + id: row.id as string, + guildId: row.guild_id as string, + auditLogEntryId: (row.audit_log_entry_id as string | null) ?? undefined, + userId: row.user_id as string, + channelId: row.channel_id as string, + actionType: row.action_type as ModerationActionRecord['actionType'], + reason: row.reason as string, + createdAt: new Date(row.created_at as string), + }; +} + +export interface MysqlStorageAdapterOptions { + uri: string; +} + +export class MysqlStorageAdapter implements StorageAdapter { + private pool: mysql.Pool; + + constructor(options: MysqlStorageAdapterOptions) { + this.pool = mysql.createPool(options.uri); + } + + async init(): Promise { + for (const statement of SCHEMA_STATEMENTS) { + await this.pool.query(statement); + } + } + + async close(): Promise { + await this.pool.end(); + } + + async getGuildConfig(guildId: string): Promise { + const [rows] = await this.pool.query('SELECT * FROM guild_configs WHERE guild_id = ?', [ + guildId, + ]); + const row = (rows as Record[])[0]; + return row ? toGuildConfig(row) : undefined; + } + + async upsertGuildConfig(config: GuildConfig): Promise { + await this.pool.query( + `INSERT INTO guild_configs + (guild_id, enabled, mode, log_channel_id, bypass_role_ids, bypass_user_ids, + block_all_invites, require_allowlist, flag_unknown_domains, mass_mention_threshold, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + enabled = VALUES(enabled), + mode = VALUES(mode), + log_channel_id = VALUES(log_channel_id), + bypass_role_ids = VALUES(bypass_role_ids), + bypass_user_ids = VALUES(bypass_user_ids), + block_all_invites = VALUES(block_all_invites), + require_allowlist = VALUES(require_allowlist), + flag_unknown_domains = VALUES(flag_unknown_domains), + mass_mention_threshold = VALUES(mass_mention_threshold), + updated_at = VALUES(updated_at)`, + [ + config.guildId, + config.enabled ? 1 : 0, + config.mode, + config.logChannelId ?? null, + JSON.stringify(config.bypassRoleIds), + JSON.stringify(config.bypassUserIds), + config.blockAllInvites ? 1 : 0, + config.requireAllowlist ? 1 : 0, + config.flagUnknownDomains ? 1 : 0, + config.massMentionThreshold, + config.createdAt.toISOString(), + config.updatedAt.toISOString(), + ], + ); + } + + async listAllowlistEntries(guildId: string): Promise { + const [rows] = await this.pool.query( + 'SELECT * FROM allowlist_entries WHERE guild_id = ? ORDER BY created_at DESC', + [guildId], + ); + return (rows as Record[]).map(toAllowlistEntry); + } + + async addAllowlistEntry(entry: AllowlistEntry): Promise { + await this.pool.query( + `INSERT INTO allowlist_entries (id, guild_id, domain, added_by, created_at) + VALUES (?, ?, ?, ?, ?)`, + [entry.id, entry.guildId, entry.domain, entry.addedBy, entry.createdAt.toISOString()], + ); + } + + async removeAllowlistEntry(guildId: string, id: string): Promise { + await this.pool.query('DELETE FROM allowlist_entries WHERE guild_id = ? AND id = ?', [ + guildId, + id, + ]); + } + + async listBlocklistEntries(guildId: string): Promise { + const [rows] = await this.pool.query( + 'SELECT * FROM blocklist_entries WHERE guild_id = ? ORDER BY created_at DESC', + [guildId], + ); + return (rows as Record[]).map(toBlocklistEntry); + } + + async addBlocklistEntry(entry: BlocklistEntry): Promise { + await this.pool.query( + `INSERT INTO blocklist_entries (id, guild_id, domain, added_by, reason, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + entry.id, + entry.guildId, + entry.domain, + entry.addedBy, + entry.reason ?? null, + entry.createdAt.toISOString(), + ], + ); + } + + async removeBlocklistEntry(guildId: string, id: string): Promise { + await this.pool.query('DELETE FROM blocklist_entries WHERE guild_id = ? AND id = ?', [ + guildId, + id, + ]); + } + + async listInviteRules(guildId: string): Promise { + const [rows] = await this.pool.query( + 'SELECT * FROM invite_rules WHERE guild_id = ? ORDER BY created_at DESC', + [guildId], + ); + return (rows as Record[]).map(toInviteRule); + } + + async addInviteRule(rule: InviteRule): Promise { + await this.pool.query( + `INSERT INTO invite_rules (id, guild_id, invite_code, added_by, created_at) + VALUES (?, ?, ?, ?, ?)`, + [rule.id, rule.guildId, rule.inviteCode, rule.addedBy, rule.createdAt.toISOString()], + ); + } + + async removeInviteRule(guildId: string, id: string): Promise { + await this.pool.query('DELETE FROM invite_rules WHERE guild_id = ? AND id = ?', [guildId, id]); + } + + async addAuditLogEntry(entry: AuditLogEntry): Promise { + await this.pool.query( + `INSERT INTO audit_log_entries + (id, guild_id, channel_id, user_id, normalized_url, hostname, verdict, reasons, score, action, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.id, + entry.guildId, + entry.channelId, + entry.userId, + entry.normalizedUrl ?? null, + entry.hostname ?? null, + entry.verdict, + JSON.stringify(entry.reasons), + entry.score, + entry.action, + entry.createdAt.toISOString(), + ], + ); + } + + async listAuditLogEntries(guildId: string, options?: ListOptions): Promise { + const limit = options?.limit ?? 1000; + const [rows] = await this.pool.query( + 'SELECT * FROM audit_log_entries WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', + [guildId, limit], + ); + return (rows as Record[]).map(toAuditLogEntry); + } + + async addScanResult(result: ScanResultRecord): Promise { + await this.pool.query( + `INSERT INTO scan_results (id, guild_id, input, normalized_url, verdict, reasons, score, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + result.id, + result.guildId ?? null, + result.input, + result.normalizedUrl ?? null, + result.verdict, + JSON.stringify(result.reasons), + result.score, + result.createdAt.toISOString(), + ], + ); + } + + async listScanResults(guildId: string, options?: ListOptions): Promise { + const limit = options?.limit ?? 1000; + const [rows] = await this.pool.query( + 'SELECT * FROM scan_results WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', + [guildId, limit], + ); + return (rows as Record[]).map(toScanResultRecord); + } + + async addModerationAction(action: ModerationActionRecord): Promise { + await this.pool.query( + `INSERT INTO moderation_actions + (id, guild_id, audit_log_entry_id, user_id, channel_id, action_type, reason, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + action.id, + action.guildId, + action.auditLogEntryId ?? null, + action.userId, + action.channelId, + action.actionType, + action.reason, + action.createdAt.toISOString(), + ], + ); + } + + async listModerationActions( + guildId: string, + options?: ListOptions, + ): Promise { + const limit = options?.limit ?? 1000; + const [rows] = await this.pool.query( + 'SELECT * FROM moderation_actions WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', + [guildId, limit], + ); + return (rows as Record[]).map(toModerationActionRecord); + } +} diff --git a/packages/storage/src/adapters/postgres.ts b/packages/storage/src/adapters/postgres.ts new file mode 100644 index 0000000..d6d3abf --- /dev/null +++ b/packages/storage/src/adapters/postgres.ts @@ -0,0 +1,412 @@ +import pg from 'pg'; +import type { + AllowlistEntry, + AuditLogEntry, + BlocklistEntry, + GuildConfig, + InviteRule, + ListOptions, + ModerationActionRecord, + ScanResultRecord, + StorageAdapter, +} from '../types.js'; + +const { Pool } = pg; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS guild_configs ( + guild_id TEXT PRIMARY KEY, + enabled BOOLEAN NOT NULL, + mode TEXT NOT NULL, + log_channel_id TEXT, + bypass_role_ids TEXT NOT NULL, + bypass_user_ids TEXT NOT NULL, + block_all_invites BOOLEAN NOT NULL, + require_allowlist BOOLEAN NOT NULL, + flag_unknown_domains BOOLEAN NOT NULL, + mass_mention_threshold INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS allowlist_entries ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + domain TEXT NOT NULL, + added_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_allowlist_guild ON allowlist_entries(guild_id); + +CREATE TABLE IF NOT EXISTS blocklist_entries ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + domain TEXT NOT NULL, + added_by TEXT NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_blocklist_guild ON blocklist_entries(guild_id); + +CREATE TABLE IF NOT EXISTS invite_rules ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + invite_code TEXT NOT NULL, + added_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_invite_rules_guild ON invite_rules(guild_id); + +CREATE TABLE IF NOT EXISTS audit_log_entries ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, + normalized_url TEXT, + hostname TEXT, + verdict TEXT NOT NULL, + reasons TEXT NOT NULL, + score INTEGER NOT NULL, + action TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_log_guild ON audit_log_entries(guild_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS scan_results ( + id TEXT PRIMARY KEY, + guild_id TEXT, + input TEXT NOT NULL, + normalized_url TEXT, + verdict TEXT NOT NULL, + reasons TEXT NOT NULL, + score INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_scan_results_guild ON scan_results(guild_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS moderation_actions ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + audit_log_entry_id TEXT, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + action_type TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_moderation_actions_guild ON moderation_actions(guild_id, created_at DESC); +`; + +function toGuildConfig(row: Record): GuildConfig { + return { + guildId: row.guild_id as string, + enabled: row.enabled as boolean, + mode: row.mode as GuildConfig['mode'], + logChannelId: (row.log_channel_id as string | null) ?? undefined, + bypassRoleIds: JSON.parse(row.bypass_role_ids as string) as string[], + bypassUserIds: JSON.parse(row.bypass_user_ids as string) as string[], + blockAllInvites: row.block_all_invites as boolean, + requireAllowlist: row.require_allowlist as boolean, + flagUnknownDomains: row.flag_unknown_domains as boolean, + massMentionThreshold: Number(row.mass_mention_threshold), + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +function toAllowlistEntry(row: Record): AllowlistEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + domain: row.domain as string, + addedBy: row.added_by as string, + createdAt: new Date(row.created_at as string), + }; +} + +function toBlocklistEntry(row: Record): BlocklistEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + domain: row.domain as string, + addedBy: row.added_by as string, + reason: (row.reason as string | null) ?? undefined, + createdAt: new Date(row.created_at as string), + }; +} + +function toInviteRule(row: Record): InviteRule { + return { + id: row.id as string, + guildId: row.guild_id as string, + inviteCode: row.invite_code as string, + addedBy: row.added_by as string, + createdAt: new Date(row.created_at as string), + }; +} + +function toAuditLogEntry(row: Record): AuditLogEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + channelId: row.channel_id as string, + userId: row.user_id as string, + normalizedUrl: (row.normalized_url as string | null) ?? undefined, + hostname: (row.hostname as string | null) ?? undefined, + verdict: row.verdict as AuditLogEntry['verdict'], + reasons: JSON.parse(row.reasons as string) as AuditLogEntry['reasons'], + score: Number(row.score), + action: row.action as AuditLogEntry['action'], + createdAt: new Date(row.created_at as string), + }; +} + +function toScanResultRecord(row: Record): ScanResultRecord { + return { + id: row.id as string, + guildId: (row.guild_id as string | null) ?? undefined, + input: row.input as string, + normalizedUrl: (row.normalized_url as string | null) ?? undefined, + verdict: row.verdict as ScanResultRecord['verdict'], + reasons: JSON.parse(row.reasons as string) as ScanResultRecord['reasons'], + score: Number(row.score), + createdAt: new Date(row.created_at as string), + }; +} + +function toModerationActionRecord(row: Record): ModerationActionRecord { + return { + id: row.id as string, + guildId: row.guild_id as string, + auditLogEntryId: (row.audit_log_entry_id as string | null) ?? undefined, + userId: row.user_id as string, + channelId: row.channel_id as string, + actionType: row.action_type as ModerationActionRecord['actionType'], + reason: row.reason as string, + createdAt: new Date(row.created_at as string), + }; +} + +export interface PostgresStorageAdapterOptions { + connectionString: string; +} + +export class PostgresStorageAdapter implements StorageAdapter { + private pool: pg.Pool; + + constructor(options: PostgresStorageAdapterOptions) { + this.pool = new Pool({ connectionString: options.connectionString }); + } + + async init(): Promise { + await this.pool.query(SCHEMA); + } + + async close(): Promise { + await this.pool.end(); + } + + async getGuildConfig(guildId: string): Promise { + const result = await this.pool.query('SELECT * FROM guild_configs WHERE guild_id = $1', [ + guildId, + ]); + return result.rows[0] ? toGuildConfig(result.rows[0]) : undefined; + } + + async upsertGuildConfig(config: GuildConfig): Promise { + await this.pool.query( + `INSERT INTO guild_configs + (guild_id, enabled, mode, log_channel_id, bypass_role_ids, bypass_user_ids, + block_all_invites, require_allowlist, flag_unknown_domains, mass_mention_threshold, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (guild_id) DO UPDATE SET + enabled = excluded.enabled, + mode = excluded.mode, + log_channel_id = excluded.log_channel_id, + bypass_role_ids = excluded.bypass_role_ids, + bypass_user_ids = excluded.bypass_user_ids, + block_all_invites = excluded.block_all_invites, + require_allowlist = excluded.require_allowlist, + flag_unknown_domains = excluded.flag_unknown_domains, + mass_mention_threshold = excluded.mass_mention_threshold, + updated_at = excluded.updated_at`, + [ + config.guildId, + config.enabled, + config.mode, + config.logChannelId ?? null, + JSON.stringify(config.bypassRoleIds), + JSON.stringify(config.bypassUserIds), + config.blockAllInvites, + config.requireAllowlist, + config.flagUnknownDomains, + config.massMentionThreshold, + config.createdAt.toISOString(), + config.updatedAt.toISOString(), + ], + ); + } + + async listAllowlistEntries(guildId: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM allowlist_entries WHERE guild_id = $1 ORDER BY created_at DESC', + [guildId], + ); + return result.rows.map(toAllowlistEntry); + } + + async addAllowlistEntry(entry: AllowlistEntry): Promise { + await this.pool.query( + `INSERT INTO allowlist_entries (id, guild_id, domain, added_by, created_at) + VALUES ($1, $2, $3, $4, $5)`, + [entry.id, entry.guildId, entry.domain, entry.addedBy, entry.createdAt.toISOString()], + ); + } + + async removeAllowlistEntry(guildId: string, id: string): Promise { + await this.pool.query('DELETE FROM allowlist_entries WHERE guild_id = $1 AND id = $2', [ + guildId, + id, + ]); + } + + async listBlocklistEntries(guildId: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM blocklist_entries WHERE guild_id = $1 ORDER BY created_at DESC', + [guildId], + ); + return result.rows.map(toBlocklistEntry); + } + + async addBlocklistEntry(entry: BlocklistEntry): Promise { + await this.pool.query( + `INSERT INTO blocklist_entries (id, guild_id, domain, added_by, reason, created_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + entry.id, + entry.guildId, + entry.domain, + entry.addedBy, + entry.reason ?? null, + entry.createdAt.toISOString(), + ], + ); + } + + async removeBlocklistEntry(guildId: string, id: string): Promise { + await this.pool.query('DELETE FROM blocklist_entries WHERE guild_id = $1 AND id = $2', [ + guildId, + id, + ]); + } + + async listInviteRules(guildId: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM invite_rules WHERE guild_id = $1 ORDER BY created_at DESC', + [guildId], + ); + return result.rows.map(toInviteRule); + } + + async addInviteRule(rule: InviteRule): Promise { + await this.pool.query( + `INSERT INTO invite_rules (id, guild_id, invite_code, added_by, created_at) + VALUES ($1, $2, $3, $4, $5)`, + [rule.id, rule.guildId, rule.inviteCode, rule.addedBy, rule.createdAt.toISOString()], + ); + } + + async removeInviteRule(guildId: string, id: string): Promise { + await this.pool.query('DELETE FROM invite_rules WHERE guild_id = $1 AND id = $2', [ + guildId, + id, + ]); + } + + async addAuditLogEntry(entry: AuditLogEntry): Promise { + await this.pool.query( + `INSERT INTO audit_log_entries + (id, guild_id, channel_id, user_id, normalized_url, hostname, verdict, reasons, score, action, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + entry.id, + entry.guildId, + entry.channelId, + entry.userId, + entry.normalizedUrl ?? null, + entry.hostname ?? null, + entry.verdict, + JSON.stringify(entry.reasons), + entry.score, + entry.action, + entry.createdAt.toISOString(), + ], + ); + } + + async listAuditLogEntries(guildId: string, options?: ListOptions): Promise { + const limit = options?.limit ?? 1000; + const result = await this.pool.query( + 'SELECT * FROM audit_log_entries WHERE guild_id = $1 ORDER BY created_at DESC LIMIT $2', + [guildId, limit], + ); + return result.rows.map(toAuditLogEntry); + } + + async addScanResult(scanResult: ScanResultRecord): Promise { + await this.pool.query( + `INSERT INTO scan_results (id, guild_id, input, normalized_url, verdict, reasons, score, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + scanResult.id, + scanResult.guildId ?? null, + scanResult.input, + scanResult.normalizedUrl ?? null, + scanResult.verdict, + JSON.stringify(scanResult.reasons), + scanResult.score, + scanResult.createdAt.toISOString(), + ], + ); + } + + async listScanResults(guildId: string, options?: ListOptions): Promise { + const limit = options?.limit ?? 1000; + const result = await this.pool.query( + 'SELECT * FROM scan_results WHERE guild_id = $1 ORDER BY created_at DESC LIMIT $2', + [guildId, limit], + ); + return result.rows.map(toScanResultRecord); + } + + async addModerationAction(action: ModerationActionRecord): Promise { + await this.pool.query( + `INSERT INTO moderation_actions + (id, guild_id, audit_log_entry_id, user_id, channel_id, action_type, reason, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + action.id, + action.guildId, + action.auditLogEntryId ?? null, + action.userId, + action.channelId, + action.actionType, + action.reason, + action.createdAt.toISOString(), + ], + ); + } + + async listModerationActions( + guildId: string, + options?: ListOptions, + ): Promise { + const limit = options?.limit ?? 1000; + const result = await this.pool.query( + 'SELECT * FROM moderation_actions WHERE guild_id = $1 ORDER BY created_at DESC LIMIT $2', + [guildId, limit], + ); + return result.rows.map(toModerationActionRecord); + } +} diff --git a/packages/storage/src/adapters/sqlite.ts b/packages/storage/src/adapters/sqlite.ts new file mode 100644 index 0000000..9447e87 --- /dev/null +++ b/packages/storage/src/adapters/sqlite.ts @@ -0,0 +1,388 @@ +import Database from 'better-sqlite3'; +import type { + AllowlistEntry, + AuditLogEntry, + BlocklistEntry, + GuildConfig, + InviteRule, + ListOptions, + ModerationActionRecord, + ScanResultRecord, + StorageAdapter, +} from '../types.js'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS guild_configs ( + guild_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL, + mode TEXT NOT NULL, + log_channel_id TEXT, + bypass_role_ids TEXT NOT NULL, + bypass_user_ids TEXT NOT NULL, + block_all_invites INTEGER NOT NULL, + require_allowlist INTEGER NOT NULL, + flag_unknown_domains INTEGER NOT NULL, + mass_mention_threshold INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS allowlist_entries ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + domain TEXT NOT NULL, + added_by TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_allowlist_guild ON allowlist_entries(guild_id); + +CREATE TABLE IF NOT EXISTS blocklist_entries ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + domain TEXT NOT NULL, + added_by TEXT NOT NULL, + reason TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_blocklist_guild ON blocklist_entries(guild_id); + +CREATE TABLE IF NOT EXISTS invite_rules ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + invite_code TEXT NOT NULL, + added_by TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_invite_rules_guild ON invite_rules(guild_id); + +CREATE TABLE IF NOT EXISTS audit_log_entries ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, + normalized_url TEXT, + hostname TEXT, + verdict TEXT NOT NULL, + reasons TEXT NOT NULL, + score INTEGER NOT NULL, + action TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_log_guild ON audit_log_entries(guild_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS scan_results ( + id TEXT PRIMARY KEY, + guild_id TEXT, + input TEXT NOT NULL, + normalized_url TEXT, + verdict TEXT NOT NULL, + reasons TEXT NOT NULL, + score INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_scan_results_guild ON scan_results(guild_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS moderation_actions ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + audit_log_entry_id TEXT, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + action_type TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_moderation_actions_guild ON moderation_actions(guild_id, created_at DESC); +`; + +function toGuildConfig(row: Record): GuildConfig { + return { + guildId: row.guild_id as string, + enabled: Boolean(row.enabled), + mode: row.mode as GuildConfig['mode'], + logChannelId: (row.log_channel_id as string | null) ?? undefined, + bypassRoleIds: JSON.parse(row.bypass_role_ids as string) as string[], + bypassUserIds: JSON.parse(row.bypass_user_ids as string) as string[], + blockAllInvites: Boolean(row.block_all_invites), + requireAllowlist: Boolean(row.require_allowlist), + flagUnknownDomains: Boolean(row.flag_unknown_domains), + massMentionThreshold: row.mass_mention_threshold as number, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +function toAllowlistEntry(row: Record): AllowlistEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + domain: row.domain as string, + addedBy: row.added_by as string, + createdAt: new Date(row.created_at as string), + }; +} + +function toBlocklistEntry(row: Record): BlocklistEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + domain: row.domain as string, + addedBy: row.added_by as string, + reason: (row.reason as string | null) ?? undefined, + createdAt: new Date(row.created_at as string), + }; +} + +function toInviteRule(row: Record): InviteRule { + return { + id: row.id as string, + guildId: row.guild_id as string, + inviteCode: row.invite_code as string, + addedBy: row.added_by as string, + createdAt: new Date(row.created_at as string), + }; +} + +function toAuditLogEntry(row: Record): AuditLogEntry { + return { + id: row.id as string, + guildId: row.guild_id as string, + channelId: row.channel_id as string, + userId: row.user_id as string, + normalizedUrl: (row.normalized_url as string | null) ?? undefined, + hostname: (row.hostname as string | null) ?? undefined, + verdict: row.verdict as AuditLogEntry['verdict'], + reasons: JSON.parse(row.reasons as string) as AuditLogEntry['reasons'], + score: row.score as number, + action: row.action as AuditLogEntry['action'], + createdAt: new Date(row.created_at as string), + }; +} + +function toScanResultRecord(row: Record): ScanResultRecord { + return { + id: row.id as string, + guildId: (row.guild_id as string | null) ?? undefined, + input: row.input as string, + normalizedUrl: (row.normalized_url as string | null) ?? undefined, + verdict: row.verdict as ScanResultRecord['verdict'], + reasons: JSON.parse(row.reasons as string) as ScanResultRecord['reasons'], + score: row.score as number, + createdAt: new Date(row.created_at as string), + }; +} + +function toModerationActionRecord(row: Record): ModerationActionRecord { + return { + id: row.id as string, + guildId: row.guild_id as string, + auditLogEntryId: (row.audit_log_entry_id as string | null) ?? undefined, + userId: row.user_id as string, + channelId: row.channel_id as string, + actionType: row.action_type as ModerationActionRecord['actionType'], + reason: row.reason as string, + createdAt: new Date(row.created_at as string), + }; +} + +export interface SqliteStorageAdapterOptions { + /** Path to the SQLite file, or ':memory:' for an ephemeral in-process database. */ + filename: string; +} + +export class SqliteStorageAdapter implements StorageAdapter { + private db: Database.Database; + + constructor(options: SqliteStorageAdapterOptions) { + this.db = new Database(options.filename); + this.db.pragma('journal_mode = WAL'); + } + + async init(): Promise { + this.db.exec(SCHEMA); + } + + async close(): Promise { + this.db.close(); + } + + async getGuildConfig(guildId: string): Promise { + const row = this.db.prepare('SELECT * FROM guild_configs WHERE guild_id = ?').get(guildId) as + Record | undefined; + return row ? toGuildConfig(row) : undefined; + } + + async upsertGuildConfig(config: GuildConfig): Promise { + this.db + .prepare( + `INSERT INTO guild_configs + (guild_id, enabled, mode, log_channel_id, bypass_role_ids, bypass_user_ids, + block_all_invites, require_allowlist, flag_unknown_domains, mass_mention_threshold, + created_at, updated_at) + VALUES (@guildId, @enabled, @mode, @logChannelId, @bypassRoleIds, @bypassUserIds, + @blockAllInvites, @requireAllowlist, @flagUnknownDomains, @massMentionThreshold, + @createdAt, @updatedAt) + ON CONFLICT(guild_id) DO UPDATE SET + enabled = excluded.enabled, + mode = excluded.mode, + log_channel_id = excluded.log_channel_id, + bypass_role_ids = excluded.bypass_role_ids, + bypass_user_ids = excluded.bypass_user_ids, + block_all_invites = excluded.block_all_invites, + require_allowlist = excluded.require_allowlist, + flag_unknown_domains = excluded.flag_unknown_domains, + mass_mention_threshold = excluded.mass_mention_threshold, + updated_at = excluded.updated_at`, + ) + .run({ + guildId: config.guildId, + enabled: config.enabled ? 1 : 0, + mode: config.mode, + logChannelId: config.logChannelId ?? null, + bypassRoleIds: JSON.stringify(config.bypassRoleIds), + bypassUserIds: JSON.stringify(config.bypassUserIds), + blockAllInvites: config.blockAllInvites ? 1 : 0, + requireAllowlist: config.requireAllowlist ? 1 : 0, + flagUnknownDomains: config.flagUnknownDomains ? 1 : 0, + massMentionThreshold: config.massMentionThreshold, + createdAt: config.createdAt.toISOString(), + updatedAt: config.updatedAt.toISOString(), + }); + } + + async listAllowlistEntries(guildId: string): Promise { + const rows = this.db + .prepare('SELECT * FROM allowlist_entries WHERE guild_id = ? ORDER BY created_at DESC') + .all(guildId) as Record[]; + return rows.map(toAllowlistEntry); + } + + async addAllowlistEntry(entry: AllowlistEntry): Promise { + this.db + .prepare( + `INSERT INTO allowlist_entries (id, guild_id, domain, added_by, created_at) + VALUES (@id, @guildId, @domain, @addedBy, @createdAt)`, + ) + .run({ ...entry, createdAt: entry.createdAt.toISOString() }); + } + + async removeAllowlistEntry(guildId: string, id: string): Promise { + this.db.prepare('DELETE FROM allowlist_entries WHERE guild_id = ? AND id = ?').run(guildId, id); + } + + async listBlocklistEntries(guildId: string): Promise { + const rows = this.db + .prepare('SELECT * FROM blocklist_entries WHERE guild_id = ? ORDER BY created_at DESC') + .all(guildId) as Record[]; + return rows.map(toBlocklistEntry); + } + + async addBlocklistEntry(entry: BlocklistEntry): Promise { + this.db + .prepare( + `INSERT INTO blocklist_entries (id, guild_id, domain, added_by, reason, created_at) + VALUES (@id, @guildId, @domain, @addedBy, @reason, @createdAt)`, + ) + .run({ ...entry, reason: entry.reason ?? null, createdAt: entry.createdAt.toISOString() }); + } + + async removeBlocklistEntry(guildId: string, id: string): Promise { + this.db.prepare('DELETE FROM blocklist_entries WHERE guild_id = ? AND id = ?').run(guildId, id); + } + + async listInviteRules(guildId: string): Promise { + const rows = this.db + .prepare('SELECT * FROM invite_rules WHERE guild_id = ? ORDER BY created_at DESC') + .all(guildId) as Record[]; + return rows.map(toInviteRule); + } + + async addInviteRule(rule: InviteRule): Promise { + this.db + .prepare( + `INSERT INTO invite_rules (id, guild_id, invite_code, added_by, created_at) + VALUES (@id, @guildId, @inviteCode, @addedBy, @createdAt)`, + ) + .run({ ...rule, createdAt: rule.createdAt.toISOString() }); + } + + async removeInviteRule(guildId: string, id: string): Promise { + this.db.prepare('DELETE FROM invite_rules WHERE guild_id = ? AND id = ?').run(guildId, id); + } + + async addAuditLogEntry(entry: AuditLogEntry): Promise { + this.db + .prepare( + `INSERT INTO audit_log_entries + (id, guild_id, channel_id, user_id, normalized_url, hostname, verdict, reasons, score, action, created_at) + VALUES (@id, @guildId, @channelId, @userId, @normalizedUrl, @hostname, @verdict, @reasons, @score, @action, @createdAt)`, + ) + .run({ + ...entry, + normalizedUrl: entry.normalizedUrl ?? null, + hostname: entry.hostname ?? null, + reasons: JSON.stringify(entry.reasons), + createdAt: entry.createdAt.toISOString(), + }); + } + + async listAuditLogEntries(guildId: string, options?: ListOptions): Promise { + const limit = options?.limit ?? 1000; + const rows = this.db + .prepare( + 'SELECT * FROM audit_log_entries WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', + ) + .all(guildId, limit) as Record[]; + return rows.map(toAuditLogEntry); + } + + async addScanResult(result: ScanResultRecord): Promise { + this.db + .prepare( + `INSERT INTO scan_results (id, guild_id, input, normalized_url, verdict, reasons, score, created_at) + VALUES (@id, @guildId, @input, @normalizedUrl, @verdict, @reasons, @score, @createdAt)`, + ) + .run({ + ...result, + guildId: result.guildId ?? null, + normalizedUrl: result.normalizedUrl ?? null, + reasons: JSON.stringify(result.reasons), + createdAt: result.createdAt.toISOString(), + }); + } + + async listScanResults(guildId: string, options?: ListOptions): Promise { + const limit = options?.limit ?? 1000; + const rows = this.db + .prepare('SELECT * FROM scan_results WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?') + .all(guildId, limit) as Record[]; + return rows.map(toScanResultRecord); + } + + async addModerationAction(action: ModerationActionRecord): Promise { + this.db + .prepare( + `INSERT INTO moderation_actions + (id, guild_id, audit_log_entry_id, user_id, channel_id, action_type, reason, created_at) + VALUES (@id, @guildId, @auditLogEntryId, @userId, @channelId, @actionType, @reason, @createdAt)`, + ) + .run({ + ...action, + auditLogEntryId: action.auditLogEntryId ?? null, + createdAt: action.createdAt.toISOString(), + }); + } + + async listModerationActions( + guildId: string, + options?: ListOptions, + ): Promise { + const limit = options?.limit ?? 1000; + const rows = this.db + .prepare( + 'SELECT * FROM moderation_actions WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', + ) + .all(guildId, limit) as Record[]; + return rows.map(toModerationActionRecord); + } +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts new file mode 100644 index 0000000..aaf94c1 --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,8 @@ +export * from './types.js'; +export { MemoryStorageAdapter } from './adapters/memory.js'; +export { SqliteStorageAdapter } from './adapters/sqlite.js'; +export type { SqliteStorageAdapterOptions } from './adapters/sqlite.js'; +export { PostgresStorageAdapter } from './adapters/postgres.js'; +export type { PostgresStorageAdapterOptions } from './adapters/postgres.js'; +export { MysqlStorageAdapter } from './adapters/mysql.js'; +export type { MysqlStorageAdapterOptions } from './adapters/mysql.js'; diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts new file mode 100644 index 0000000..a2345bc --- /dev/null +++ b/packages/storage/src/types.ts @@ -0,0 +1,134 @@ +import type { + DetectionReason, + EnforcementMode, + ModerationActionType, + Verdict, +} from '@antilink-guard/core'; + +export interface GuildConfig { + guildId: string; + enabled: boolean; + mode: EnforcementMode; + logChannelId?: string; + bypassRoleIds: string[]; + bypassUserIds: string[]; + blockAllInvites: boolean; + requireAllowlist: boolean; + flagUnknownDomains: boolean; + massMentionThreshold: number; + createdAt: Date; + updatedAt: Date; +} + +export function createDefaultGuildConfig(guildId: string): GuildConfig { + const now = new Date(); + return { + guildId, + enabled: true, + mode: 'delete', + bypassRoleIds: [], + bypassUserIds: [], + blockAllInvites: false, + requireAllowlist: false, + flagUnknownDomains: false, + massMentionThreshold: 0, + createdAt: now, + updatedAt: now, + }; +} + +export interface AllowlistEntry { + id: string; + guildId: string; + domain: string; + addedBy: string; + createdAt: Date; +} + +export interface BlocklistEntry { + id: string; + guildId: string; + domain: string; + addedBy: string; + reason?: string; + createdAt: Date; +} + +export interface InviteRule { + id: string; + guildId: string; + inviteCode: string; + addedBy: string; + createdAt: Date; +} + +/** A metadata-only record of a moderation-relevant scan. Never includes message content. */ +export interface AuditLogEntry { + id: string; + guildId: string; + channelId: string; + userId: string; + normalizedUrl?: string; + hostname?: string; + verdict: Verdict; + reasons: DetectionReason[]; + score: number; + action: ModerationActionType; + createdAt: Date; +} + +/** A persisted scan outcome, e.g. from /testlink or the CLI - not tied to a moderated message. */ +export interface ScanResultRecord { + id: string; + guildId?: string; + input: string; + normalizedUrl?: string; + verdict: Verdict; + reasons: DetectionReason[]; + score: number; + createdAt: Date; +} + +export interface ModerationActionRecord { + id: string; + guildId: string; + auditLogEntryId?: string; + userId: string; + channelId: string; + actionType: ModerationActionType; + reason: string; + createdAt: Date; +} + +export interface ListOptions { + limit?: number; +} + +export interface StorageAdapter { + init(): Promise; + close(): Promise; + + getGuildConfig(guildId: string): Promise; + upsertGuildConfig(config: GuildConfig): Promise; + + listAllowlistEntries(guildId: string): Promise; + addAllowlistEntry(entry: AllowlistEntry): Promise; + removeAllowlistEntry(guildId: string, id: string): Promise; + + listBlocklistEntries(guildId: string): Promise; + addBlocklistEntry(entry: BlocklistEntry): Promise; + removeBlocklistEntry(guildId: string, id: string): Promise; + + listInviteRules(guildId: string): Promise; + addInviteRule(rule: InviteRule): Promise; + removeInviteRule(guildId: string, id: string): Promise; + + addAuditLogEntry(entry: AuditLogEntry): Promise; + listAuditLogEntries(guildId: string, options?: ListOptions): Promise; + + addScanResult(result: ScanResultRecord): Promise; + listScanResults(guildId: string, options?: ListOptions): Promise; + + addModerationAction(action: ModerationActionRecord): Promise; + listModerationActions(guildId: string, options?: ListOptions): Promise; +} diff --git a/packages/storage/test/contract.ts b/packages/storage/test/contract.ts new file mode 100644 index 0000000..e147a85 --- /dev/null +++ b/packages/storage/test/contract.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import type { + AllowlistEntry, + AuditLogEntry, + BlocklistEntry, + InviteRule, + ModerationActionRecord, + ScanResultRecord, + StorageAdapter, +} from '../src/types.js'; +import { createDefaultGuildConfig } from '../src/types.js'; + +/** + * A shared behavioral contract every StorageAdapter implementation must satisfy. + * Run this against each adapter so memory/SQLite/MySQL/Postgres stay interchangeable. + */ +export function runStorageAdapterContractTests( + adapterName: string, + createAdapter: () => Promise, +): void { + describe(`StorageAdapter contract: ${adapterName}`, () => { + async function withAdapter(fn: (adapter: StorageAdapter) => Promise): Promise { + const adapter = await createAdapter(); + await adapter.init(); + try { + await fn(adapter); + } finally { + await adapter.close(); + } + } + + it('returns undefined for a guild with no stored config', async () => { + await withAdapter(async (adapter) => { + const config = await adapter.getGuildConfig(`guild-${randomUUID()}`); + expect(config).toBeUndefined(); + }); + }); + + it('round-trips a guild config through upsert and get', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const config = { + ...createDefaultGuildConfig(guildId), + logChannelId: 'channel-123', + bypassRoleIds: ['role-1', 'role-2'], + mode: 'timeout' as const, + }; + + await adapter.upsertGuildConfig(config); + const fetched = await adapter.getGuildConfig(guildId); + + expect(fetched?.guildId).toBe(guildId); + expect(fetched?.logChannelId).toBe('channel-123'); + expect(fetched?.bypassRoleIds).toEqual(['role-1', 'role-2']); + expect(fetched?.mode).toBe('timeout'); + }); + }); + + it('updates an existing guild config on a second upsert rather than duplicating it', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const config = createDefaultGuildConfig(guildId); + + await adapter.upsertGuildConfig(config); + await adapter.upsertGuildConfig({ ...config, enabled: false, mode: 'log' }); + + const fetched = await adapter.getGuildConfig(guildId); + expect(fetched?.enabled).toBe(false); + expect(fetched?.mode).toBe('log'); + }); + }); + + it('adds and lists allowlist entries scoped to a guild', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const otherGuildId = `guild-${randomUUID()}`; + const entry: AllowlistEntry = { + id: randomUUID(), + guildId, + domain: 'example.com', + addedBy: 'user-1', + createdAt: new Date(), + }; + + await adapter.addAllowlistEntry(entry); + await adapter.addAllowlistEntry({ ...entry, id: randomUUID(), guildId: otherGuildId }); + + const entries = await adapter.listAllowlistEntries(guildId); + expect(entries).toHaveLength(1); + expect(entries[0]?.domain).toBe('example.com'); + }); + }); + + it('removes an allowlist entry by id', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const entry: AllowlistEntry = { + id: randomUUID(), + guildId, + domain: 'example.com', + addedBy: 'user-1', + createdAt: new Date(), + }; + await adapter.addAllowlistEntry(entry); + await adapter.removeAllowlistEntry(guildId, entry.id); + expect(await adapter.listAllowlistEntries(guildId)).toHaveLength(0); + }); + }); + + it('adds and lists blocklist entries with an optional reason', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const entry: BlocklistEntry = { + id: randomUUID(), + guildId, + domain: 'bad-site.com', + addedBy: 'user-1', + reason: 'reported phishing', + createdAt: new Date(), + }; + await adapter.addBlocklistEntry(entry); + const entries = await adapter.listBlocklistEntries(guildId); + expect(entries[0]?.reason).toBe('reported phishing'); + }); + }); + + it('removes a blocklist entry by id', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const entry: BlocklistEntry = { + id: randomUUID(), + guildId, + domain: 'bad-site.com', + addedBy: 'user-1', + createdAt: new Date(), + }; + await adapter.addBlocklistEntry(entry); + await adapter.removeBlocklistEntry(guildId, entry.id); + expect(await adapter.listBlocklistEntries(guildId)).toHaveLength(0); + }); + }); + + it('adds, lists, and removes invite rules', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const rule: InviteRule = { + id: randomUUID(), + guildId, + inviteCode: 'abc123', + addedBy: 'user-1', + createdAt: new Date(), + }; + await adapter.addInviteRule(rule); + expect(await adapter.listInviteRules(guildId)).toHaveLength(1); + + await adapter.removeInviteRule(guildId, rule.id); + expect(await adapter.listInviteRules(guildId)).toHaveLength(0); + }); + }); + + it('stores and lists audit log entries newest-first, without message content', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const older: AuditLogEntry = { + id: randomUUID(), + guildId, + channelId: 'channel-1', + userId: 'user-1', + normalizedUrl: 'https://bad-site.com', + hostname: 'bad-site.com', + verdict: 'BLOCK', + reasons: ['BLOCKLIST_MATCH'], + score: 50, + action: 'DELETE', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + }; + const newer: AuditLogEntry = { + ...older, + id: randomUUID(), + createdAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + await adapter.addAuditLogEntry(older); + await adapter.addAuditLogEntry(newer); + + const entries = await adapter.listAuditLogEntries(guildId); + expect(entries).toHaveLength(2); + expect(entries[0]?.id).toBe(newer.id); + expect(entries.every((e) => !('content' in e))).toBe(true); + }); + }); + + it('respects the limit option when listing audit log entries', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + for (let i = 0; i < 5; i++) { + await adapter.addAuditLogEntry({ + id: randomUUID(), + guildId, + channelId: 'channel-1', + userId: 'user-1', + verdict: 'WARN', + reasons: ['UNKNOWN_DOMAIN'], + score: 5, + action: 'LOG', + createdAt: new Date(), + }); + } + const entries = await adapter.listAuditLogEntries(guildId, { limit: 2 }); + expect(entries).toHaveLength(2); + }); + }); + + it('stores and lists scan results, including guild-less CLI scans', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const record: ScanResultRecord = { + id: randomUUID(), + guildId, + input: 'https://example.com', + normalizedUrl: 'https://example.com', + verdict: 'ALLOW', + reasons: [], + score: 0, + createdAt: new Date(), + }; + await adapter.addScanResult(record); + const results = await adapter.listScanResults(guildId); + expect(results).toHaveLength(1); + expect(results[0]?.verdict).toBe('ALLOW'); + }); + }); + + it('stores and lists moderation actions', async () => { + await withAdapter(async (adapter) => { + const guildId = `guild-${randomUUID()}`; + const action: ModerationActionRecord = { + id: randomUUID(), + guildId, + userId: 'user-1', + channelId: 'channel-1', + actionType: 'DELETE', + reason: 'blocklisted domain', + createdAt: new Date(), + }; + await adapter.addModerationAction(action); + const actions = await adapter.listModerationActions(guildId); + expect(actions).toHaveLength(1); + expect(actions[0]?.actionType).toBe('DELETE'); + }); + }); + }); +} diff --git a/packages/storage/test/memory.test.ts b/packages/storage/test/memory.test.ts new file mode 100644 index 0000000..6109de7 --- /dev/null +++ b/packages/storage/test/memory.test.ts @@ -0,0 +1,4 @@ +import { MemoryStorageAdapter } from '../src/adapters/memory.js'; +import { runStorageAdapterContractTests } from './contract.js'; + +runStorageAdapterContractTests('memory', async () => new MemoryStorageAdapter()); diff --git a/packages/storage/test/mysql.test.ts b/packages/storage/test/mysql.test.ts new file mode 100644 index 0000000..af4b3fa --- /dev/null +++ b/packages/storage/test/mysql.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from 'vitest'; +import { MysqlStorageAdapter } from '../src/adapters/mysql.js'; +import { runStorageAdapterContractTests } from './contract.js'; + +const uri = process.env.MYSQL_TEST_URL; + +if (uri) { + runStorageAdapterContractTests('mysql', async () => new MysqlStorageAdapter({ uri })); +} else { + describe('StorageAdapter contract: mysql', () => { + it.skip('set MYSQL_TEST_URL to a reachable MySQL/MariaDB instance to run these tests', () => {}); + }); +} diff --git a/packages/storage/test/postgres.test.ts b/packages/storage/test/postgres.test.ts new file mode 100644 index 0000000..5351cb3 --- /dev/null +++ b/packages/storage/test/postgres.test.ts @@ -0,0 +1,11 @@ +import { PostgresStorageAdapter } from '../src/adapters/postgres.js'; +import { runStorageAdapterContractTests } from './contract.js'; + +const connectionString = + process.env.POSTGRES_TEST_URL ?? + 'postgres://postgres:postgres@localhost:5432/antilink_guard_test'; + +runStorageAdapterContractTests( + 'postgres', + async () => new PostgresStorageAdapter({ connectionString }), +); diff --git a/packages/storage/test/sqlite.test.ts b/packages/storage/test/sqlite.test.ts new file mode 100644 index 0000000..71c1e40 --- /dev/null +++ b/packages/storage/test/sqlite.test.ts @@ -0,0 +1,7 @@ +import { SqliteStorageAdapter } from '../src/adapters/sqlite.js'; +import { runStorageAdapterContractTests } from './contract.js'; + +runStorageAdapterContractTests( + 'sqlite', + async () => new SqliteStorageAdapter({ filename: ':memory:' }), +); diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/storage/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2867d..096ef50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,40 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.20.0) + packages/storage: + dependencies: + '@antilink-guard/core': + specifier: workspace:* + version: link:../core + better-sqlite3: + specifier: ^11.8.1 + version: 11.10.0 + mysql2: + specifier: ^3.12.0 + version: 3.22.5(@types/node@22.20.0) + pg: + specifier: ^8.13.1 + version: 8.22.0 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.12 + version: 7.6.13 + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + '@types/pg': + specifier: ^8.11.10 + version: 8.20.0 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.0) + packages: '@esbuild/aix-ppc64@0.21.5': @@ -559,6 +593,9 @@ packages: cpu: [x64] os: [win32] + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -568,6 +605,9 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@typescript-eslint/eslint-plugin@8.63.0': resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -683,6 +723,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -690,6 +734,18 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -697,6 +753,9 @@ packages: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -727,6 +786,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -761,13 +823,32 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -840,6 +921,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -866,6 +951,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -880,11 +968,20 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -901,6 +998,13 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -917,6 +1021,12 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -925,6 +1035,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -970,12 +1083,23 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -983,27 +1107,53 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.22.5: + resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1038,6 +1188,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1074,6 +1258,28 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1083,10 +1289,21 @@ packages: engines: {node: '>=14'} hasBin: true + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1104,6 +1321,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1120,6 +1343,12 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1128,12 +1357,27 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1147,6 +1391,13 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1208,6 +1459,9 @@ packages: typescript: optional: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1233,6 +1487,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1308,6 +1565,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1612,6 +1876,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.20.0 + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -1620,6 +1888,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 22.20.0 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1774,10 +2048,29 @@ snapshots: assertion-error@2.0.1: {} + aws-ssl-profiles@1.1.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + base64-js@1.5.1: {} + + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -1787,6 +2080,11 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -1815,6 +2113,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1839,10 +2139,24 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} + denque@2.1.0: {} + + detect-libc@2.1.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -1978,6 +2292,8 @@ snapshots: esutils@2.0.3: {} + expand-template@2.0.3: {} + expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} @@ -1994,6 +2310,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2012,9 +2330,17 @@ snapshots: flatted@3.4.2: {} + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + github-from-package@0.0.0: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2025,6 +2351,12 @@ snapshots: has-flag@4.0.0: {} + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2036,12 +2368,18 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + ini@1.3.8: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-property@1.0.2: {} + isexe@2.0.0: {} joycon@3.1.1: {} @@ -2077,12 +2415,18 @@ snapshots: lodash.merge@4.6.2: {} + long@5.3.2: {} + loupe@3.2.1: {} + lru.min@1.1.4: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mimic-response@3.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -2091,6 +2435,10 @@ snapshots: dependencies: brace-expansion: 1.1.15 + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -2100,18 +2448,44 @@ snapshots: ms@2.1.3: {} + mysql2@3.22.5(@types/node@22.20.0): + dependencies: + '@types/node': 22.20.0 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.15: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2143,6 +2517,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -2167,12 +2576,55 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@3.9.4: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} resolve-from@4.0.0: {} @@ -2210,6 +2662,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + semver@7.8.5: {} shebang-command@2.0.0: @@ -2220,14 +2676,32 @@ snapshots: siginfo@2.0.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + source-map-js@1.2.1: {} source-map@0.7.6: {} + split2@4.2.0: {} + + sql-escaper@1.3.3: {} + stackback@0.0.2: {} std-env@3.10.0: {} + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} sucrase@3.35.1: @@ -2244,6 +2718,21 @@ snapshots: dependencies: has-flag: 4.0.0 + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2303,6 +2792,10 @@ snapshots: - tsx - yaml + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2328,6 +2821,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite-node@2.1.9(@types/node@22.20.0): dependencies: cac: 6.7.14 @@ -2401,4 +2896,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + + xtend@4.0.2: {} + yocto-queue@0.1.0: {} From 7e7ff9064fe47732521ae13e447adfc1044025a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:50:42 +0000 Subject: [PATCH 5/9] feat: add packages/cli (the antilink command) and storage config bundles packages/cli - a standalone CLI usable without ever touching Discord: - antilink scan "" - run the full policy engine on message text - antilink test-url - classify a single URL - antilink init - scaffold .env.example + antilink.config.json - antilink export-config - dump a guild's config/allowlist/blocklist/ invite rules (from SQLite) to JSON - antilink import-config - load that JSON back into a (possibly fresh) database - antilink doctor - offline health checks: Node version, .env presence, DISCORD_TOKEN sanity, config file validity, the better-sqlite3 native binding, and DB directory write access Local policy is a zod-validated antilink.config.json mapping directly to @antilink-guard/core's PolicyConfig. Exit codes are meaningful (scan/test-url exit 1 on BLOCK/QUARANTINE, doctor exits 1 on any failed check) so the CLI is scriptable in CI. packages/storage gains a shared config-bundle module (export/import/parse, zod-validated) so the CLI's export-config/import-config and the upcoming discord-bot's /config export//import commands operate on the exact same bundle format instead of duplicating it. 35 new tests, run against the actual compiled CLI (spawned via commander's parseAsync, not just the underlying functions) plus a real export -> JSON -> import round trip through a live SQLite file. One real bug was caught and fixed during this work: a test helper's mock-teardown order was clearing `.mock.calls` before the assertions read them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- package.json | 9 +- packages/cli/package.json | 35 +++++ packages/cli/src/commands/doctor.ts | 139 ++++++++++++++++++++ packages/cli/src/commands/export-config.ts | 36 +++++ packages/cli/src/commands/import-config.ts | 36 +++++ packages/cli/src/commands/init.ts | 60 +++++++++ packages/cli/src/commands/scan.ts | 35 +++++ packages/cli/src/commands/test-url.ts | 42 ++++++ packages/cli/src/format.ts | 34 +++++ packages/cli/src/index.ts | 27 ++++ packages/cli/src/load-policy.ts | 26 ++++ packages/cli/src/policy-config-schema.ts | 44 +++++++ packages/cli/test/cli.test.ts | 128 ++++++++++++++++++ packages/cli/test/doctor.test.ts | 70 ++++++++++ packages/cli/test/load-policy.test.ts | 49 +++++++ packages/cli/tsconfig.json | 8 ++ packages/storage/package.json | 3 +- packages/storage/src/config-bundle.ts | 127 ++++++++++++++++++ packages/storage/src/index.ts | 8 ++ packages/storage/test/config-bundle.test.ts | 128 ++++++++++++++++++ pnpm-lock.yaml | 48 +++++++ 21 files changed, 1088 insertions(+), 4 deletions(-) create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/cli/src/commands/export-config.ts create mode 100644 packages/cli/src/commands/import-config.ts create mode 100644 packages/cli/src/commands/init.ts create mode 100644 packages/cli/src/commands/scan.ts create mode 100644 packages/cli/src/commands/test-url.ts create mode 100644 packages/cli/src/format.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/load-policy.ts create mode 100644 packages/cli/src/policy-config-schema.ts create mode 100644 packages/cli/test/cli.test.ts create mode 100644 packages/cli/test/doctor.test.ts create mode 100644 packages/cli/test/load-policy.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/storage/src/config-bundle.ts create mode 100644 packages/storage/test/config-bundle.test.ts diff --git a/package.json b/package.json index 1a87a68..7b9b375 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "homepage": "https://antil.ink", "repository": { "type": "git", - "url": "git+https://github.com/timeout187/Anti-Links-Discord-Bot.git" + "url": "git+https://github.com/timeout187/antilink-guard.git" }, "bugs": { - "url": "https://github.com/timeout187/Anti-Links-Discord-Bot/issues" + "url": "https://github.com/timeout187/antilink-guard/issues" }, "scripts": { "build": "pnpm -r --filter \"./packages/*\" build", @@ -40,6 +40,9 @@ "vitest": "^2.1.8" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild", "better-sqlite3"] + "onlyBuiltDependencies": [ + "esbuild", + "better-sqlite3" + ] } } diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..89700a2 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@antilink-guard/cli", + "version": "0.1.0", + "description": "Command-line tool for AntiLink Guard OSS: scan text, test URLs, and manage guild config.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "antilink": "./dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@antilink-guard/core": "workspace:*", + "@antilink-guard/storage": "workspace:*", + "better-sqlite3": "^11.8.1", + "commander": "^13.1.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..a247449 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,139 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { access, constants as fsConstants } from 'node:fs/promises'; +import type { Command } from 'commander'; +import Database from 'better-sqlite3'; +import { DEFAULT_CONFIG_FILENAME } from '../load-policy.js'; +import { policyConfigFileSchema } from '../policy-config-schema.js'; + +export type CheckStatus = 'pass' | 'warn' | 'fail'; + +export interface CheckResult { + name: string; + status: CheckStatus; + message: string; +} + +export function checkNodeVersion(): CheckResult { + const [major] = process.versions.node.split('.').map(Number); + if (major !== undefined && major >= 20) { + return { name: 'Node.js version', status: 'pass', message: `v${process.versions.node}` }; + } + return { + name: 'Node.js version', + status: 'fail', + message: `v${process.versions.node} - AntiLink Guard OSS requires Node.js 20 or newer`, + }; +} + +export function checkEnvFile(): CheckResult { + if (existsSync('.env')) { + return { name: '.env file', status: 'pass', message: 'found' }; + } + return { + name: '.env file', + status: 'warn', + message: 'not found - run `antilink init` and copy .env.example to .env', + }; +} + +export function checkDiscordToken(): CheckResult { + const token = process.env.DISCORD_TOKEN; + if (!token) { + return { name: 'DISCORD_TOKEN', status: 'warn', message: 'not set in the current environment' }; + } + if (token.length < 40) { + return { + name: 'DISCORD_TOKEN', + status: 'warn', + message: 'set, but shorter than a real Discord bot token - check for a placeholder value', + }; + } + return { name: 'DISCORD_TOKEN', status: 'pass', message: 'set' }; +} + +export async function checkPolicyConfigFile(): Promise { + if (!existsSync(DEFAULT_CONFIG_FILENAME)) { + return { + name: DEFAULT_CONFIG_FILENAME, + status: 'warn', + message: 'not found - built-in defaults will be used', + }; + } + try { + const raw = await readFile(DEFAULT_CONFIG_FILENAME, 'utf8'); + policyConfigFileSchema.parse(JSON.parse(raw)); + return { name: DEFAULT_CONFIG_FILENAME, status: 'pass', message: 'found and valid' }; + } catch (error) { + return { + name: DEFAULT_CONFIG_FILENAME, + status: 'fail', + message: `invalid: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export function checkSqliteBinding(): CheckResult { + try { + const db = new Database(':memory:'); + db.exec('SELECT 1'); + db.close(); + return { + name: 'better-sqlite3 native binding', + status: 'pass', + message: 'loaded successfully', + }; + } catch (error) { + return { + name: 'better-sqlite3 native binding', + status: 'fail', + message: `failed to load: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export async function checkSqliteDirectoryWritable(dbPath: string): Promise { + const dir = dirname(dbPath) || '.'; + try { + await access(dir, fsConstants.W_OK); + return { name: 'SQLite directory writable', status: 'pass', message: dir }; + } catch { + return { + name: 'SQLite directory writable', + status: 'fail', + message: `no write permission for ${dir}`, + }; + } +} + +const STATUS_ICON: Record = { pass: '✓', warn: '!', fail: '✗' }; + +export function registerDoctorCommand(program: Command): void { + program + .command('doctor') + .description('Run offline health checks against your local AntiLink Guard OSS setup') + .option('--db ', 'path to the SQLite database file to check', './antilink.sqlite') + .action(async (options: { db: string }) => { + const checks: CheckResult[] = [ + checkNodeVersion(), + checkEnvFile(), + checkDiscordToken(), + await checkPolicyConfigFile(), + checkSqliteBinding(), + await checkSqliteDirectoryWritable(options.db), + ]; + + for (const check of checks) { + console.log(`[${STATUS_ICON[check.status]}] ${check.name}: ${check.message}`); + } + + const hasFailure = checks.some((c) => c.status === 'fail'); + if (hasFailure) { + console.log('\nOne or more checks failed.'); + process.exitCode = 1; + } else { + console.log('\nAll checks passed (warnings, if any, are non-blocking).'); + } + }); +} diff --git a/packages/cli/src/commands/export-config.ts b/packages/cli/src/commands/export-config.ts new file mode 100644 index 0000000..48b70ed --- /dev/null +++ b/packages/cli/src/commands/export-config.ts @@ -0,0 +1,36 @@ +import { writeFile } from 'node:fs/promises'; +import type { Command } from 'commander'; +import { SqliteStorageAdapter, exportGuildConfigBundle } from '@antilink-guard/storage'; + +interface ExportConfigOptions { + guild: string; + db: string; + out?: string; +} + +export function registerExportConfigCommand(program: Command): void { + program + .command('export-config') + .description("Export a guild's configuration, allowlist, blocklist, and invite rules as JSON") + .requiredOption('-g, --guild ', 'Discord guild (server) ID to export') + .option('--db ', 'path to the SQLite database file', './antilink.sqlite') + .option('-o, --out ', 'write to a file instead of stdout') + .action(async (options: ExportConfigOptions) => { + const adapter = new SqliteStorageAdapter({ filename: options.db }); + await adapter.init(); + + try { + const bundle = await exportGuildConfigBundle(adapter, options.guild); + const json = `${JSON.stringify(bundle, null, 2)}\n`; + + if (options.out) { + await writeFile(options.out, json, 'utf8'); + console.error(`Wrote config bundle for guild ${options.guild} to ${options.out}`); + } else { + process.stdout.write(json); + } + } finally { + await adapter.close(); + } + }); +} diff --git a/packages/cli/src/commands/import-config.ts b/packages/cli/src/commands/import-config.ts new file mode 100644 index 0000000..496f1c9 --- /dev/null +++ b/packages/cli/src/commands/import-config.ts @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises'; +import type { Command } from 'commander'; +import { + SqliteStorageAdapter, + importGuildConfigBundle, + parseConfigBundle, +} from '@antilink-guard/storage'; + +interface ImportConfigOptions { + db: string; + merge?: boolean; +} + +export function registerImportConfigCommand(program: Command): void { + program + .command('import-config ') + .description('Import a config bundle previously produced by export-config') + .option('--db ', 'path to the SQLite database file', './antilink.sqlite') + .option('--merge', 'merge into existing allow/block/invite lists instead of replacing them') + .action(async (file: string, options: ImportConfigOptions) => { + const raw = await readFile(file, 'utf8'); + const bundle = parseConfigBundle(JSON.parse(raw)); + + const adapter = new SqliteStorageAdapter({ filename: options.db }); + await adapter.init(); + + try { + await importGuildConfigBundle(adapter, bundle, { + replaceExisting: !(options.merge ?? false), + }); + console.error(`Imported config for guild ${bundle.guildConfig.guildId} from ${file}`); + } finally { + await adapter.close(); + } + }); +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..28359f7 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,60 @@ +import { writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import type { Command } from 'commander'; +import { DEFAULT_CONFIG_FILENAME } from '../load-policy.js'; +import { EXAMPLE_POLICY_CONFIG } from '../policy-config-schema.js'; + +const ENV_TEMPLATE = `# AntiLink Guard OSS - environment configuration +# Copy this file's values into your real .env and never commit that file. + +# Required: your Discord bot token (Developer Portal -> Bot -> Token) +DISCORD_TOKEN= + +# Optional: comma-separated Discord application/guild IDs to restrict slash-command +# registration to, useful for local development. Leave blank to register globally. +DISCORD_GUILD_IDS= + +# Storage: defaults to a local SQLite file. See docs/self-hosting.md for +# MySQL/PostgreSQL connection strings. +DATABASE_DRIVER=sqlite +DATABASE_SQLITE_PATH=./antilink.sqlite +`; + +interface InitOptions { + force?: boolean; + dir?: string; +} + +async function writeIfAbsent(path: string, content: string, force: boolean): Promise { + if (!force && existsSync(path)) { + return `skipped (already exists): ${path}`; + } + await writeFile(path, content, 'utf8'); + return `wrote ${path}`; +} + +export function registerInitCommand(program: Command): void { + program + .command('init') + .description('Scaffold a .env and antilink.config.json in the current directory') + .option('-f, --force', 'overwrite existing files') + .option('-d, --dir ', 'directory to scaffold into', '.') + .action(async (options: InitOptions) => { + const dir = options.dir ?? '.'; + const force = options.force ?? false; + + const envResult = await writeIfAbsent(`${dir}/.env.example`, ENV_TEMPLATE, force); + const configResult = await writeIfAbsent( + `${dir}/${DEFAULT_CONFIG_FILENAME}`, + `${JSON.stringify(EXAMPLE_POLICY_CONFIG, null, 2)}\n`, + force, + ); + + console.log(envResult); + console.log(configResult); + console.log('\nNext steps:'); + console.log(' 1. Copy .env.example to .env and fill in DISCORD_TOKEN.'); + console.log(` 2. Adjust ${DEFAULT_CONFIG_FILENAME} to your server's rules.`); + console.log(' 3. Run `antilink doctor` to verify your setup.'); + }); +} diff --git a/packages/cli/src/commands/scan.ts b/packages/cli/src/commands/scan.ts new file mode 100644 index 0000000..df0b80b --- /dev/null +++ b/packages/cli/src/commands/scan.ts @@ -0,0 +1,35 @@ +import type { Command } from 'commander'; +import { evaluateMessage } from '@antilink-guard/core'; +import { loadPolicyConfig } from '../load-policy.js'; +import { formatScanResult } from '../format.js'; + +interface ScanOptions { + config?: string; + json?: boolean; +} + +export function registerScanCommand(program: Command): void { + program + .command('scan ') + .description('Scan a message string through the detection and policy engine') + .option('-c, --config ', 'path to antilink.config.json') + .option('--json', 'print raw JSON instead of a formatted report') + .action(async (message: string, options: ScanOptions) => { + const policy = await loadPolicyConfig(options.config); + const result = evaluateMessage( + message, + { guildId: 'cli', channelId: 'cli', authorId: 'cli' }, + policy, + ); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatScanResult(result)); + } + + if (result.verdict === 'BLOCK' || result.verdict === 'QUARANTINE') { + process.exitCode = 1; + } + }); +} diff --git a/packages/cli/src/commands/test-url.ts b/packages/cli/src/commands/test-url.ts new file mode 100644 index 0000000..0e17887 --- /dev/null +++ b/packages/cli/src/commands/test-url.ts @@ -0,0 +1,42 @@ +import type { Command } from 'commander'; +import { classifyLink, extractLinks, VERDICT_THRESHOLDS } from '@antilink-guard/core'; +import { loadPolicyConfig } from '../load-policy.js'; + +interface TestUrlOptions { + config?: string; + json?: boolean; +} + +export function registerTestUrlCommand(program: Command): void { + program + .command('test-url ') + .description('Classify a single URL without running the full message pipeline') + .option('-c, --config ', 'path to antilink.config.json') + .option('--json', 'print raw JSON instead of a formatted report') + .action(async (url: string, options: TestUrlOptions) => { + const policy = await loadPolicyConfig(options.config); + const [link] = extractLinks(url); + + if (!link) { + console.error(`Could not parse "${url}" as a URL.`); + process.exitCode = 1; + return; + } + + const classification = classifyLink(link, policy, url); + + if (options.json) { + console.log(JSON.stringify(classification, null, 2)); + } else { + const reasons = + classification.reasons.length > 0 ? classification.reasons.join(', ') : 'none'; + console.log(`URL: ${classification.link.normalizedUrl}`); + console.log(`Score: ${classification.score}`); + console.log(`Reasons: ${reasons}`); + } + + if (classification.score >= VERDICT_THRESHOLDS.block) { + process.exitCode = 1; + } + }); +} diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts new file mode 100644 index 0000000..c55e8bb --- /dev/null +++ b/packages/cli/src/format.ts @@ -0,0 +1,34 @@ +import type { ScanResult } from '@antilink-guard/core'; + +const VERDICT_LABEL: Record = { + ALLOW: 'ALLOW', + WARN: 'WARN', + BLOCK: 'BLOCK', + QUARANTINE: 'QUARANTINE', +}; + +export function formatScanResult(result: ScanResult): string { + const lines = [ + `Verdict: ${VERDICT_LABEL[result.verdict]}`, + `Action: ${result.action}`, + `Score: ${result.score}`, + ]; + + if (result.bypassed) { + lines.push('Bypassed: true (role, user, or channel exemption)'); + } + + if (result.reasons.length > 0) { + lines.push(`Reasons: ${result.reasons.join(', ')}`); + } + + if (result.matchedLinks.length > 0) { + lines.push('Links:'); + for (const match of result.matchedLinks) { + const reasons = match.reasons.length > 0 ? match.reasons.join(', ') : 'none'; + lines.push(` - ${match.link.normalizedUrl} (score ${match.score}, reasons: ${reasons})`); + } + } + + return lines.join('\n'); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..6a2691f --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { registerScanCommand } from './commands/scan.js'; +import { registerTestUrlCommand } from './commands/test-url.js'; +import { registerInitCommand } from './commands/init.js'; +import { registerExportConfigCommand } from './commands/export-config.js'; +import { registerImportConfigCommand } from './commands/import-config.js'; +import { registerDoctorCommand } from './commands/doctor.js'; + +export function createProgram(): Command { + const program = new Command(); + program.name('antilink').description('AntiLink Guard OSS command-line tool').version('0.1.0'); + + registerScanCommand(program); + registerTestUrlCommand(program); + registerInitCommand(program); + registerExportConfigCommand(program); + registerImportConfigCommand(program); + registerDoctorCommand(program); + + return program; +} + +const isMainModule = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + await createProgram().parseAsync(process.argv); +} diff --git a/packages/cli/src/load-policy.ts b/packages/cli/src/load-policy.ts new file mode 100644 index 0000000..79ab690 --- /dev/null +++ b/packages/cli/src/load-policy.ts @@ -0,0 +1,26 @@ +import { readFile } from 'node:fs/promises'; +import type { PolicyConfig } from '@antilink-guard/core'; +import { DEFAULT_POLICY_CONFIG, policyConfigFileSchema } from './policy-config-schema.js'; + +export const DEFAULT_CONFIG_FILENAME = 'antilink.config.json'; + +function isEnoent(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +export async function loadPolicyConfig( + path: string = DEFAULT_CONFIG_FILENAME, +): Promise { + try { + const raw = await readFile(path, 'utf8'); + return policyConfigFileSchema.parse(JSON.parse(raw)); + } catch (error) { + if (isEnoent(error)) return DEFAULT_POLICY_CONFIG; + throw error; + } +} diff --git a/packages/cli/src/policy-config-schema.ts b/packages/cli/src/policy-config-schema.ts new file mode 100644 index 0000000..f592e90 --- /dev/null +++ b/packages/cli/src/policy-config-schema.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const regexRuleSchema = z.object({ + id: z.string().min(1), + pattern: z.string().min(1), + flags: z.string().optional(), + score: z.number().int().optional(), + target: z.enum(['url', 'message']).optional(), +}); + +const channelRuleSchema = z.object({ + channelId: z.string().min(1), + mode: z.enum(['exempt', 'log', 'delete', 'timeout']), +}); + +export const policyConfigFileSchema = z.object({ + enabled: z.boolean().default(true), + mode: z.enum(['log', 'delete', 'timeout']).default('log'), + channelRules: z.array(channelRuleSchema).default([]), + bypassRoleIds: z.array(z.string()).default([]), + bypassUserIds: z.array(z.string()).default([]), + domainAllowlist: z.array(z.string()).default([]), + domainBlocklist: z.array(z.string()).default([]), + knownPhishingDomains: z.array(z.string()).default([]), + inviteAllowlist: z.array(z.string()).default([]), + blockAllInvites: z.boolean().default(false), + regexRules: z.array(regexRuleSchema).default([]), + urlShortenerDomains: z.array(z.string()).optional(), + requireAllowlist: z.boolean().default(false), + flagUnknownDomains: z.boolean().default(false), + massMentionThreshold: z.number().int().min(0).default(0), +}); + +export type PolicyConfigFile = z.infer; + +export const DEFAULT_POLICY_CONFIG: PolicyConfigFile = policyConfigFileSchema.parse({}); + +export const EXAMPLE_POLICY_CONFIG: PolicyConfigFile = policyConfigFileSchema.parse({ + enabled: true, + mode: 'log', + domainBlocklist: [], + domainAllowlist: [], + flagUnknownDomains: false, +}); diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..185faa6 --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createProgram } from '../src/index.js'; + +async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.exitCode = undefined; + + let stdout = ''; + let stderr = ''; + try { + await createProgram().parseAsync(['node', 'antilink', ...args]); + } finally { + stdout = [ + ...stdoutSpy.mock.calls.map((c) => String(c[0])), + ...logSpy.mock.calls.map((c) => c.join(' ')), + ].join('\n'); + stderr = [ + ...stderrSpy.mock.calls.map((c) => String(c[0])), + ...errorSpy.mock.calls.map((c) => c.join(' ')), + ].join('\n'); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + + const exitCode = process.exitCode ?? 0; + process.exitCode = undefined; + return { stdout, stderr, exitCode }; +} + +describe('antilink CLI', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'antilink-cli-e2e-')); + tempDirs.push(dir); + return dir; + } + + it('scan reports ALLOW for a clean message with no config file', async () => { + const { stdout, exitCode } = await run(['scan', 'hello world', '--json']); + const result = JSON.parse(stdout); + expect(result.verdict).toBe('ALLOW'); + expect(exitCode).toBe(0); + }); + + it('scan exits non-zero for a message hitting a blocklisted domain', async () => { + const dir = await makeTempDir(); + const configPath = join(dir, 'antilink.config.json'); + await writeFile( + configPath, + JSON.stringify({ mode: 'delete', domainBlocklist: ['bad-site.com'] }), + 'utf8', + ); + + const { stdout, exitCode } = await run([ + 'scan', + 'check out https://bad-site.com', + '--config', + configPath, + '--json', + ]); + const result = JSON.parse(stdout); + expect(result.verdict).toBe('BLOCK'); + expect(exitCode).toBe(1); + }); + + it('test-url classifies a single URL', async () => { + const { stdout, exitCode } = await run(['test-url', 'https://bit.ly/abc', '--json']); + const result = JSON.parse(stdout); + expect(result.reasons).toContain('URL_SHORTENER'); + expect(exitCode).toBe(0); + }); + + it('init scaffolds .env.example and antilink.config.json without overwriting existing files', async () => { + const dir = await makeTempDir(); + await run(['init', '--dir', dir]); + + expect(existsSync(join(dir, '.env.example'))).toBe(true); + expect(existsSync(join(dir, 'antilink.config.json'))).toBe(true); + + await writeFile(join(dir, 'antilink.config.json'), '{"custom":true}', 'utf8'); + await run(['init', '--dir', dir]); + const content = await readFile(join(dir, 'antilink.config.json'), 'utf8'); + expect(content).toBe('{"custom":true}'); + }); + + it('doctor runs all checks and exits 0 in a healthy environment', async () => { + const dir = await makeTempDir(); + const { exitCode } = await run(['doctor', '--db', join(dir, 'antilink.sqlite')]); + expect(exitCode).toBe(0); + }); + + it('exports a guild config bundle then imports it into a fresh database', async () => { + const dir = await makeTempDir(); + const sourceDb = join(dir, 'source.sqlite'); + const destDb = join(dir, 'dest.sqlite'); + const bundlePath = join(dir, 'bundle.json'); + + await run(['export-config', '--guild', 'guild-123', '--db', sourceDb, '--out', bundlePath]); + + const bundleContent = await readFile(bundlePath, 'utf8'); + const bundle = JSON.parse(bundleContent); + expect(bundle.guildConfig.guildId).toBe('guild-123'); + + await run(['import-config', bundlePath, '--db', destDb]); + + const { SqliteStorageAdapter } = await import('@antilink-guard/storage'); + const adapter = new SqliteStorageAdapter({ filename: destDb }); + await adapter.init(); + const imported = await adapter.getGuildConfig('guild-123'); + await adapter.close(); + + expect(imported?.guildId).toBe('guild-123'); + }); +}); diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts new file mode 100644 index 0000000..5c5250d --- /dev/null +++ b/packages/cli/test/doctor.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, afterEach, beforeEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + checkDiscordToken, + checkNodeVersion, + checkSqliteBinding, + checkSqliteDirectoryWritable, +} from '../src/commands/doctor.js'; + +describe('doctor checks', () => { + it('checkNodeVersion passes on the Node version running the tests (>=20)', () => { + const result = checkNodeVersion(); + expect(result.status).toBe('pass'); + }); + + it('checkSqliteBinding passes when better-sqlite3 loads', () => { + const result = checkSqliteBinding(); + expect(result.status).toBe('pass'); + }); + + describe('checkDiscordToken', () => { + const original = process.env.DISCORD_TOKEN; + + afterEach(() => { + if (original === undefined) delete process.env.DISCORD_TOKEN; + else process.env.DISCORD_TOKEN = original; + }); + + it('warns when DISCORD_TOKEN is unset', () => { + delete process.env.DISCORD_TOKEN; + expect(checkDiscordToken().status).toBe('warn'); + }); + + it('warns when DISCORD_TOKEN looks like a placeholder', () => { + process.env.DISCORD_TOKEN = 'your-token-here'; + expect(checkDiscordToken().status).toBe('warn'); + }); + + it('passes when DISCORD_TOKEN looks like a real token', () => { + process.env.DISCORD_TOKEN = 'x'.repeat(59); + expect(checkDiscordToken().status).toBe('pass'); + }); + }); + + describe('checkSqliteDirectoryWritable', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'antilink-doctor-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('passes when the directory is writable', async () => { + const result = await checkSqliteDirectoryWritable(join(dir, 'antilink.sqlite')); + expect(result.status).toBe('pass'); + }); + + it('fails when the directory does not exist', async () => { + const result = await checkSqliteDirectoryWritable( + join(dir, 'missing-subdir', 'antilink.sqlite'), + ); + expect(result.status).toBe('fail'); + }); + }); +}); diff --git a/packages/cli/test/load-policy.test.ts b/packages/cli/test/load-policy.test.ts new file mode 100644 index 0000000..f463372 --- /dev/null +++ b/packages/cli/test/load-policy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadPolicyConfig } from '../src/load-policy.js'; +import { DEFAULT_POLICY_CONFIG } from '../src/policy-config-schema.js'; + +describe('loadPolicyConfig', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'antilink-cli-')); + tempDirs.push(dir); + return dir; + } + + it('returns the default policy when no config file exists', async () => { + const dir = await makeTempDir(); + const policy = await loadPolicyConfig(join(dir, 'missing.json')); + expect(policy).toEqual(DEFAULT_POLICY_CONFIG); + }); + + it('loads and validates a config file that exists', async () => { + const dir = await makeTempDir(); + const path = join(dir, 'antilink.config.json'); + await writeFile( + path, + JSON.stringify({ mode: 'delete', domainBlocklist: ['bad-site.com'] }), + 'utf8', + ); + + const policy = await loadPolicyConfig(path); + expect(policy.mode).toBe('delete'); + expect(policy.domainBlocklist).toEqual(['bad-site.com']); + expect(policy.enabled).toBe(true); + }); + + it('rejects a config file with an invalid mode', async () => { + const dir = await makeTempDir(); + const path = join(dir, 'antilink.config.json'); + await writeFile(path, JSON.stringify({ mode: 'not-a-real-mode' }), 'utf8'); + + await expect(loadPolicyConfig(path)).rejects.toThrow(); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/storage/package.json b/packages/storage/package.json index 54ec379..a7be29d 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -25,7 +25,8 @@ "@antilink-guard/core": "workspace:*", "better-sqlite3": "^11.8.1", "mysql2": "^3.12.0", - "pg": "^8.13.1" + "pg": "^8.13.1", + "zod": "^3.24.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", diff --git a/packages/storage/src/config-bundle.ts b/packages/storage/src/config-bundle.ts new file mode 100644 index 0000000..8f955ed --- /dev/null +++ b/packages/storage/src/config-bundle.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; +import type { + AllowlistEntry, + BlocklistEntry, + GuildConfig, + InviteRule, + StorageAdapter, +} from './types.js'; +import { createDefaultGuildConfig } from './types.js'; + +export const CONFIG_BUNDLE_VERSION = 1 as const; + +const enforcementModeSchema = z.enum(['log', 'delete', 'timeout']); + +const guildConfigSchema = z.object({ + guildId: z.string().min(1), + enabled: z.boolean(), + mode: enforcementModeSchema, + logChannelId: z.string().optional(), + bypassRoleIds: z.array(z.string()), + bypassUserIds: z.array(z.string()), + blockAllInvites: z.boolean(), + requireAllowlist: z.boolean(), + flagUnknownDomains: z.boolean(), + massMentionThreshold: z.number().int().min(0), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +const allowlistEntrySchema = z.object({ + id: z.string().min(1), + guildId: z.string().min(1), + domain: z.string().min(1), + addedBy: z.string().min(1), + createdAt: z.coerce.date(), +}); + +const blocklistEntrySchema = z.object({ + id: z.string().min(1), + guildId: z.string().min(1), + domain: z.string().min(1), + addedBy: z.string().min(1), + reason: z.string().optional(), + createdAt: z.coerce.date(), +}); + +const inviteRuleSchema = z.object({ + id: z.string().min(1), + guildId: z.string().min(1), + inviteCode: z.string().min(1), + addedBy: z.string().min(1), + createdAt: z.coerce.date(), +}); + +export const configBundleSchema = z.object({ + version: z.literal(CONFIG_BUNDLE_VERSION), + guildConfig: guildConfigSchema, + allowlist: z.array(allowlistEntrySchema), + blocklist: z.array(blocklistEntrySchema), + inviteRules: z.array(inviteRuleSchema), +}); + +export interface ConfigBundle { + version: typeof CONFIG_BUNDLE_VERSION; + guildConfig: GuildConfig; + allowlist: AllowlistEntry[]; + blocklist: BlocklistEntry[]; + inviteRules: InviteRule[]; +} + +export async function exportGuildConfigBundle( + adapter: StorageAdapter, + guildId: string, +): Promise { + const [guildConfig, allowlist, blocklist, inviteRules] = await Promise.all([ + adapter.getGuildConfig(guildId), + adapter.listAllowlistEntries(guildId), + adapter.listBlocklistEntries(guildId), + adapter.listInviteRules(guildId), + ]); + + return { + version: CONFIG_BUNDLE_VERSION, + guildConfig: guildConfig ?? createDefaultGuildConfig(guildId), + allowlist, + blocklist, + inviteRules, + }; +} + +export interface ImportConfigBundleOptions { + /** Remove existing allowlist/blocklist/invite entries for this guild before importing. Defaults to true. */ + replaceExisting?: boolean; +} + +export async function importGuildConfigBundle( + adapter: StorageAdapter, + bundle: ConfigBundle, + options: ImportConfigBundleOptions = {}, +): Promise { + const replaceExisting = options.replaceExisting ?? true; + const { guildId } = bundle.guildConfig; + + if (replaceExisting) { + const [existingAllowlist, existingBlocklist, existingInviteRules] = await Promise.all([ + adapter.listAllowlistEntries(guildId), + adapter.listBlocklistEntries(guildId), + adapter.listInviteRules(guildId), + ]); + await Promise.all([ + ...existingAllowlist.map((e) => adapter.removeAllowlistEntry(guildId, e.id)), + ...existingBlocklist.map((e) => adapter.removeBlocklistEntry(guildId, e.id)), + ...existingInviteRules.map((e) => adapter.removeInviteRule(guildId, e.id)), + ]); + } + + await adapter.upsertGuildConfig(bundle.guildConfig); + await Promise.all([ + ...bundle.allowlist.map((e) => adapter.addAllowlistEntry(e)), + ...bundle.blocklist.map((e) => adapter.addBlocklistEntry(e)), + ...bundle.inviteRules.map((e) => adapter.addInviteRule(e)), + ]); +} + +export function parseConfigBundle(json: unknown): ConfigBundle { + return configBundleSchema.parse(json); +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index aaf94c1..bea147f 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -6,3 +6,11 @@ export { PostgresStorageAdapter } from './adapters/postgres.js'; export type { PostgresStorageAdapterOptions } from './adapters/postgres.js'; export { MysqlStorageAdapter } from './adapters/mysql.js'; export type { MysqlStorageAdapterOptions } from './adapters/mysql.js'; +export { + CONFIG_BUNDLE_VERSION, + configBundleSchema, + exportGuildConfigBundle, + importGuildConfigBundle, + parseConfigBundle, +} from './config-bundle.js'; +export type { ConfigBundle, ImportConfigBundleOptions } from './config-bundle.js'; diff --git a/packages/storage/test/config-bundle.test.ts b/packages/storage/test/config-bundle.test.ts new file mode 100644 index 0000000..b0fbd8b --- /dev/null +++ b/packages/storage/test/config-bundle.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { MemoryStorageAdapter } from '../src/adapters/memory.js'; +import { createDefaultGuildConfig } from '../src/types.js'; +import { + configBundleSchema, + exportGuildConfigBundle, + importGuildConfigBundle, + parseConfigBundle, +} from '../src/config-bundle.js'; + +describe('config bundle export/import', () => { + it('exports the default config for a guild with no stored data', async () => { + const adapter = new MemoryStorageAdapter(); + await adapter.init(); + const guildId = `guild-${randomUUID()}`; + + const bundle = await exportGuildConfigBundle(adapter, guildId); + + expect(bundle.version).toBe(1); + expect(bundle.guildConfig.guildId).toBe(guildId); + expect(bundle.allowlist).toHaveLength(0); + expect(bundle.blocklist).toHaveLength(0); + expect(bundle.inviteRules).toHaveLength(0); + }); + + it('round-trips a full bundle through export, JSON serialization, and import into a fresh adapter', async () => { + const source = new MemoryStorageAdapter(); + await source.init(); + const guildId = `guild-${randomUUID()}`; + + await source.upsertGuildConfig({ + ...createDefaultGuildConfig(guildId), + mode: 'timeout', + logChannelId: 'channel-log', + }); + await source.addAllowlistEntry({ + id: randomUUID(), + guildId, + domain: 'example.com', + addedBy: 'user-1', + createdAt: new Date(), + }); + await source.addBlocklistEntry({ + id: randomUUID(), + guildId, + domain: 'bad-site.com', + addedBy: 'user-1', + reason: 'phishing report', + createdAt: new Date(), + }); + await source.addInviteRule({ + id: randomUUID(), + guildId, + inviteCode: 'abc123', + addedBy: 'user-1', + createdAt: new Date(), + }); + + const exported = await exportGuildConfigBundle(source, guildId); + + // Simulate writing to a file and reading it back. + const roundTripped = parseConfigBundle(JSON.parse(JSON.stringify(exported))); + + const destination = new MemoryStorageAdapter(); + await destination.init(); + await importGuildConfigBundle(destination, roundTripped); + + const reExported = await exportGuildConfigBundle(destination, guildId); + expect(reExported.guildConfig.mode).toBe('timeout'); + expect(reExported.guildConfig.logChannelId).toBe('channel-log'); + expect(reExported.allowlist).toHaveLength(1); + expect(reExported.allowlist[0]?.domain).toBe('example.com'); + expect(reExported.blocklist[0]?.reason).toBe('phishing report'); + expect(reExported.inviteRules[0]?.inviteCode).toBe('abc123'); + }); + + it('replaces existing entries on import by default rather than duplicating them', async () => { + const adapter = new MemoryStorageAdapter(); + await adapter.init(); + const guildId = `guild-${randomUUID()}`; + + await adapter.addAllowlistEntry({ + id: randomUUID(), + guildId, + domain: 'old-domain.com', + addedBy: 'user-1', + createdAt: new Date(), + }); + + const bundle = { + version: 1 as const, + guildConfig: createDefaultGuildConfig(guildId), + allowlist: [ + { + id: randomUUID(), + guildId, + domain: 'new-domain.com', + addedBy: 'user-1', + createdAt: new Date(), + }, + ], + blocklist: [], + inviteRules: [], + }; + + await importGuildConfigBundle(adapter, bundle); + + const entries = await adapter.listAllowlistEntries(guildId); + expect(entries).toHaveLength(1); + expect(entries[0]?.domain).toBe('new-domain.com'); + }); + + it('rejects a malformed bundle via schema validation', () => { + expect(() => parseConfigBundle({ version: 1, guildConfig: {} })).toThrow(); + }); + + it('rejects an unsupported bundle version', () => { + const validShape = { + version: 2, + guildConfig: createDefaultGuildConfig('guild-1'), + allowlist: [], + blocklist: [], + inviteRules: [], + }; + expect(() => configBundleSchema.parse(validShape)).toThrow(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 096ef50..ba74e96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,40 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.20.0) + packages/cli: + dependencies: + '@antilink-guard/core': + specifier: workspace:* + version: link:../core + '@antilink-guard/storage': + specifier: workspace:* + version: link:../storage + better-sqlite3: + specifier: ^11.8.1 + version: 11.10.0 + commander: + specifier: ^13.1.0 + version: 13.1.0 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.12 + version: 7.6.13 + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.0) + packages/core: devDependencies: '@types/node': @@ -68,6 +102,9 @@ importers: pg: specifier: ^8.13.1 version: 8.22.0 + zod: + specifier: ^3.24.1 + version: 3.25.76 devDependencies: '@types/better-sqlite3': specifier: ^7.6.12 @@ -796,6 +833,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1576,6 +1617,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@esbuild/aix-ppc64@0.21.5': @@ -2121,6 +2165,8 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -2901,3 +2947,5 @@ snapshots: xtend@4.0.2: {} yocto-queue@0.1.0: {} + + zod@3.25.76: {} From 5990d4e9368e09df86d58fe2a83b74a86f9c967a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 22:06:16 +0000 Subject: [PATCH 6/9] feat: add packages/discord-bot (discord.js v14 adapter + slash commands) The Discord runtime for AntiLink Guard OSS: all 7 required slash commands plus the messageCreate moderation pipeline. Commands (all admin ones gated to Manage Server via setDefaultMemberPermissions; /testlink is open to everyone): - /antilink status|enable|disable|mode - /allowlist add|remove domain: - /blocklist add|remove domain: - /invites allow invite: | block-all [enabled:] - /logs set-channel channel: - /testlink url: - /config export|import (JSON file attachment) Runtime pipeline (events/message-create.ts): - ignores bots and DMs; skips disabled guilds - builds a PolicyConfig from the guild's stored config + allow/block lists + invite rules, runs it through @antilink-guard/core's evaluateMessage - gates message deletion/timeout on discord.js's own permission signals (message.deletable, member.moderatable) - moderation/enforce.ts depends on a narrow structural interface rather than the full discord.js Message class, specifically so it's unit-testable with plain fakes - records an AuditLogEntry (metadata only - no message content) and a ModerationActionRecord for every non-ALLOW verdict, and posts a mod-log embed (user, channel, normalized URL, action, reason, score, timestamp) to the configured log channel - a per-guild token-bucket rate limiter caps enforcement actions so a spam wave can't drive the bot into Discord's own API rate limits Also extends @antilink-guard/core's EnforcementMode/ModerationActionType with a `warn` tier (log/warn/delete/timeout), which /antilink mode's block/warn/log choices need - `warn` mode flags BLOCK/QUARANTINE-severity content without deleting it. All existing core tests still pass unchanged; 3 new policy-engine tests cover the new tier. /config import always overwrites the imported bundle's guildId with the guild the command was run in, so an admin can never accidentally (or maliciously) write another server's data into their own database. 122 new tests (68 in discord-bot, plus 3 core + 1 storage-schema addition), all against fakes/mocks of discord.js's Message/Interaction/Client shapes - there's no live Discord connection in this environment (no bot token, no gateway), so this is unit coverage of the actual logic, not an end-to-end test against real Discord. 186 tests pass across the whole workspace. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- packages/cli/src/policy-config-schema.ts | 4 +- packages/core/src/policy/policy-engine.ts | 4 +- packages/core/src/types/index.ts | 4 +- packages/core/test/policy-engine.test.ts | 30 ++ packages/discord-bot/package.json | 36 ++ packages/discord-bot/src/bot.ts | 53 +++ .../discord-bot/src/commands/allowlist.ts | 70 ++++ packages/discord-bot/src/commands/antilink.ts | 82 +++++ .../discord-bot/src/commands/blocklist.ts | 70 ++++ packages/discord-bot/src/commands/config.ts | 74 ++++ packages/discord-bot/src/commands/index.ts | 36 ++ packages/discord-bot/src/commands/invites.ts | 75 ++++ packages/discord-bot/src/commands/logs.ts | 46 +++ packages/discord-bot/src/commands/testlink.ts | 52 +++ packages/discord-bot/src/commands/types.ts | 21 ++ packages/discord-bot/src/context.ts | 7 + .../src/events/interaction-create.ts | 30 ++ .../discord-bot/src/events/message-create.ts | 63 ++++ packages/discord-bot/src/index.ts | 19 + packages/discord-bot/src/logger.ts | 11 + .../discord-bot/src/moderation/enforce.ts | 114 ++++++ .../discord-bot/src/moderation/mod-log.ts | 72 ++++ .../discord-bot/src/policy-from-config.ts | 37 ++ packages/discord-bot/src/rate-limit.ts | 43 +++ packages/discord-bot/src/register-commands.ts | 24 ++ packages/discord-bot/test/commands.test.ts | 331 ++++++++++++++++++ packages/discord-bot/test/enforce.test.ts | 155 ++++++++ .../test/interaction-create.test.ts | 88 +++++ .../discord-bot/test/message-create.test.ts | 184 ++++++++++ packages/discord-bot/test/mod-log.test.ts | 91 +++++ .../test/policy-from-config.test.ts | 75 ++++ packages/discord-bot/test/rate-limit.test.ts | 67 ++++ packages/discord-bot/tsconfig.json | 8 + packages/storage/src/config-bundle.ts | 2 +- pnpm-lock.yaml | 307 ++++++++++++++++ vitest.config.ts | 3 + 36 files changed, 2382 insertions(+), 6 deletions(-) create mode 100644 packages/discord-bot/package.json create mode 100644 packages/discord-bot/src/bot.ts create mode 100644 packages/discord-bot/src/commands/allowlist.ts create mode 100644 packages/discord-bot/src/commands/antilink.ts create mode 100644 packages/discord-bot/src/commands/blocklist.ts create mode 100644 packages/discord-bot/src/commands/config.ts create mode 100644 packages/discord-bot/src/commands/index.ts create mode 100644 packages/discord-bot/src/commands/invites.ts create mode 100644 packages/discord-bot/src/commands/logs.ts create mode 100644 packages/discord-bot/src/commands/testlink.ts create mode 100644 packages/discord-bot/src/commands/types.ts create mode 100644 packages/discord-bot/src/context.ts create mode 100644 packages/discord-bot/src/events/interaction-create.ts create mode 100644 packages/discord-bot/src/events/message-create.ts create mode 100644 packages/discord-bot/src/index.ts create mode 100644 packages/discord-bot/src/logger.ts create mode 100644 packages/discord-bot/src/moderation/enforce.ts create mode 100644 packages/discord-bot/src/moderation/mod-log.ts create mode 100644 packages/discord-bot/src/policy-from-config.ts create mode 100644 packages/discord-bot/src/rate-limit.ts create mode 100644 packages/discord-bot/src/register-commands.ts create mode 100644 packages/discord-bot/test/commands.test.ts create mode 100644 packages/discord-bot/test/enforce.test.ts create mode 100644 packages/discord-bot/test/interaction-create.test.ts create mode 100644 packages/discord-bot/test/message-create.test.ts create mode 100644 packages/discord-bot/test/mod-log.test.ts create mode 100644 packages/discord-bot/test/policy-from-config.test.ts create mode 100644 packages/discord-bot/test/rate-limit.test.ts create mode 100644 packages/discord-bot/tsconfig.json diff --git a/packages/cli/src/policy-config-schema.ts b/packages/cli/src/policy-config-schema.ts index f592e90..6f0469d 100644 --- a/packages/cli/src/policy-config-schema.ts +++ b/packages/cli/src/policy-config-schema.ts @@ -10,12 +10,12 @@ const regexRuleSchema = z.object({ const channelRuleSchema = z.object({ channelId: z.string().min(1), - mode: z.enum(['exempt', 'log', 'delete', 'timeout']), + mode: z.enum(['exempt', 'log', 'warn', 'delete', 'timeout']), }); export const policyConfigFileSchema = z.object({ enabled: z.boolean().default(true), - mode: z.enum(['log', 'delete', 'timeout']).default('log'), + mode: z.enum(['log', 'warn', 'delete', 'timeout']).default('log'), channelRules: z.array(channelRuleSchema).default([]), bypassRoleIds: z.array(z.string()).default([]), bypassUserIds: z.array(z.string()).default([]), diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 5791f25..7d7709f 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -41,8 +41,10 @@ function scoreToVerdict(score: number): Verdict { function verdictToAction(verdict: Verdict, mode: EnforcementMode): ModerationActionType { if (verdict === 'ALLOW') return 'NONE'; - if (mode === 'log') return 'LOG'; if (verdict === 'WARN') return 'LOG'; + // verdict is BLOCK or QUARANTINE from here - `mode` decides the ceiling. + if (mode === 'log') return 'LOG'; + if (mode === 'warn') return 'WARN'; if (mode === 'delete') return 'DELETE'; // mode === 'timeout' return verdict === 'QUARANTINE' ? 'TIMEOUT' : 'DELETE'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index e0cff7d..6c064f6 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -13,9 +13,9 @@ export type DetectionReason = | 'REGEX_RULE_MATCH' | 'NOT_IN_ALLOWLIST'; -export type EnforcementMode = 'log' | 'delete' | 'timeout'; +export type EnforcementMode = 'log' | 'warn' | 'delete' | 'timeout'; -export type ModerationActionType = 'NONE' | 'LOG' | 'DELETE' | 'TIMEOUT'; +export type ModerationActionType = 'NONE' | 'LOG' | 'WARN' | 'DELETE' | 'TIMEOUT'; export interface ExtractedLink { /** The exact substring matched in the de-obfuscated text. */ diff --git a/packages/core/test/policy-engine.test.ts b/packages/core/test/policy-engine.test.ts index c4a34e1..5287668 100644 --- a/packages/core/test/policy-engine.test.ts +++ b/packages/core/test/policy-engine.test.ts @@ -102,6 +102,36 @@ describe('evaluateMessage', () => { expect(result.action).toBe('TIMEOUT'); }); + it('returns a WARN action (not DELETE) for a BLOCK verdict in warn mode', () => { + const result = evaluateMessage('https://bad-site.com', context, { + ...basePolicy, + mode: 'warn', + domainBlocklist: ['bad-site.com'], + }); + expect(result.verdict).toBe('BLOCK'); + expect(result.action).toBe('WARN'); + }); + + it('returns a WARN action for a QUARANTINE verdict in warn mode (no timeout)', () => { + const result = evaluateMessage('https://phish.example', context, { + ...basePolicy, + mode: 'warn', + knownPhishingDomains: ['phish.example'], + }); + expect(result.verdict).toBe('QUARANTINE'); + expect(result.action).toBe('WARN'); + }); + + it('still returns a plain LOG action for a WARN-severity verdict in warn mode', () => { + const result = evaluateMessage('https://example.com', context, { + ...basePolicy, + mode: 'warn', + flagUnknownDomains: true, + }); + expect(result.verdict).toBe('WARN'); + expect(result.action).toBe('LOG'); + }); + it('only deletes (does not time out) a plain BLOCK verdict in timeout mode', () => { const result = evaluateMessage('https://bad-site.com', context, { ...basePolicy, diff --git a/packages/discord-bot/package.json b/packages/discord-bot/package.json new file mode 100644 index 0000000..73894de --- /dev/null +++ b/packages/discord-bot/package.json @@ -0,0 +1,36 @@ +{ + "name": "@antilink-guard/discord-bot", + "version": "0.1.0", + "description": "discord.js v14 adapter for AntiLink Guard OSS: slash commands and the message moderation pipeline.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@antilink-guard/core": "workspace:*", + "@antilink-guard/storage": "workspace:*", + "discord.js": "^14.26.4", + "pino": "^10.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/discord-bot/src/bot.ts b/packages/discord-bot/src/bot.ts new file mode 100644 index 0000000..0e831d0 --- /dev/null +++ b/packages/discord-bot/src/bot.ts @@ -0,0 +1,53 @@ +import { Client, Events, GatewayIntentBits } from 'discord.js'; +import type { StorageAdapter } from '@antilink-guard/storage'; +import { TokenBucketRateLimiter } from './rate-limit.js'; +import { handleMessageCreate } from './events/message-create.js'; +import { handleInteractionCreate } from './events/interaction-create.js'; +import type { BotContext } from './context.js'; +import { logger } from './logger.js'; + +export interface ModerationRateLimitOptions { + capacity: number; + refillPerSecond: number; +} + +export interface CreateBotOptions { + storage: StorageAdapter; + moderationRateLimit?: ModerationRateLimitOptions; +} + +export function createBot(options: CreateBotOptions): Client { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }); + + const ctx: BotContext = { + storage: options.storage, + moderationRateLimiter: new TokenBucketRateLimiter( + options.moderationRateLimit?.capacity ?? 10, + options.moderationRateLimit?.refillPerSecond ?? 2, + ), + }; + + client.once(Events.ClientReady, (readyClient) => { + logger.info({ user: readyClient.user.tag }, 'AntiLink Guard OSS is ready'); + }); + + client.on(Events.MessageCreate, (message) => { + handleMessageCreate(message, ctx).catch((error: unknown) => { + logger.error({ err: error }, 'unhandled error in message-create handler'); + }); + }); + + client.on(Events.InteractionCreate, (interaction) => { + handleInteractionCreate(interaction, ctx).catch((error: unknown) => { + logger.error({ err: error }, 'unhandled error in interaction-create handler'); + }); + }); + + return client; +} diff --git a/packages/discord-bot/src/commands/allowlist.ts b/packages/discord-bot/src/commands/allowlist.ts new file mode 100644 index 0000000..d4108f3 --- /dev/null +++ b/packages/discord-bot/src/commands/allowlist.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; +import { + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +const builder = new SlashCommandBuilder() + .setName('allowlist') + .setDescription('Manage the domain allowlist') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Allow a domain') + .addStringOption((opt) => + opt + .setName('domain') + .setDescription('Domain to allow (e.g. example.com)') + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a domain from the allowlist') + .addStringOption((opt) => + opt.setName('domain').setDescription('Domain to remove').setRequired(true), + ), + ); + +async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(true); + const domain = interaction.options.getString('domain', true).toLowerCase().trim(); + + if (sub === 'add') { + await ctx.storage.addAllowlistEntry({ + id: randomUUID(), + guildId, + domain, + addedBy: interaction.user.id, + createdAt: new Date(), + }); + await interaction.reply({ ephemeral: true, content: `Added **${domain}** to the allowlist.` }); + return; + } + + const entries = await ctx.storage.listAllowlistEntries(guildId); + const match = entries.find((entry) => entry.domain === domain); + if (!match) { + await interaction.reply({ + ephemeral: true, + content: `**${domain}** is not on the allowlist.`, + }); + return; + } + await ctx.storage.removeAllowlistEntry(guildId, match.id); + await interaction.reply({ + ephemeral: true, + content: `Removed **${domain}** from the allowlist.`, + }); +} + +export const allowlistCommand: SlashCommand = { name: 'allowlist', builder, execute }; diff --git a/packages/discord-bot/src/commands/antilink.ts b/packages/discord-bot/src/commands/antilink.ts new file mode 100644 index 0000000..c10b3d5 --- /dev/null +++ b/packages/discord-bot/src/commands/antilink.ts @@ -0,0 +1,82 @@ +import { + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +const MODE_CHOICES = [ + { name: 'Block (delete matching messages)', value: 'block' }, + { name: 'Warn (notify, do not delete)', value: 'warn' }, + { name: 'Log only (take no action)', value: 'log' }, +] as const; + +function toEnforcementMode(choice: string): 'delete' | 'warn' | 'log' { + return choice === 'block' ? 'delete' : (choice as 'warn' | 'log'); +} + +const builder = new SlashCommandBuilder() + .setName('antilink') + .setDescription('Manage AntiLink Guard protection for this server') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => + sub.setName('status').setDescription('Show the current AntiLink configuration'), + ) + .addSubcommand((sub) => sub.setName('enable').setDescription('Turn on link moderation')) + .addSubcommand((sub) => sub.setName('disable').setDescription('Turn off link moderation')) + .addSubcommand((sub) => + sub + .setName('mode') + .setDescription('Set the enforcement mode') + .addStringOption((opt) => + opt + .setName('value') + .setDescription('Enforcement mode') + .setRequired(true) + .addChoices(...MODE_CHOICES), + ), + ); + +async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(true); + const existing = (await ctx.storage.getGuildConfig(guildId)) ?? createDefaultGuildConfig(guildId); + + if (sub === 'status') { + await interaction.reply({ + ephemeral: true, + content: [ + `**Enabled:** ${existing.enabled}`, + `**Mode:** ${existing.mode}`, + `**Log channel:** ${existing.logChannelId ? `<#${existing.logChannelId}>` : 'not set'}`, + `**Block all invites:** ${existing.blockAllInvites}`, + ].join('\n'), + }); + return; + } + + if (sub === 'enable' || sub === 'disable') { + await ctx.storage.upsertGuildConfig({ + ...existing, + enabled: sub === 'enable', + updatedAt: new Date(), + }); + await interaction.reply({ + ephemeral: true, + content: `AntiLink Guard is now **${sub === 'enable' ? 'enabled' : 'disabled'}**.`, + }); + return; + } + + const choice = interaction.options.getString('value', true); + const mode = toEnforcementMode(choice); + await ctx.storage.upsertGuildConfig({ ...existing, mode, updatedAt: new Date() }); + await interaction.reply({ ephemeral: true, content: `Enforcement mode set to **${mode}**.` }); +} + +export const antilinkCommand: SlashCommand = { name: 'antilink', builder, execute }; diff --git a/packages/discord-bot/src/commands/blocklist.ts b/packages/discord-bot/src/commands/blocklist.ts new file mode 100644 index 0000000..f957956 --- /dev/null +++ b/packages/discord-bot/src/commands/blocklist.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; +import { + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +const builder = new SlashCommandBuilder() + .setName('blocklist') + .setDescription('Manage the domain blocklist') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Block a domain') + .addStringOption((opt) => + opt + .setName('domain') + .setDescription('Domain to block (e.g. bad-site.com)') + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a domain from the blocklist') + .addStringOption((opt) => + opt.setName('domain').setDescription('Domain to remove').setRequired(true), + ), + ); + +async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(true); + const domain = interaction.options.getString('domain', true).toLowerCase().trim(); + + if (sub === 'add') { + await ctx.storage.addBlocklistEntry({ + id: randomUUID(), + guildId, + domain, + addedBy: interaction.user.id, + createdAt: new Date(), + }); + await interaction.reply({ ephemeral: true, content: `Added **${domain}** to the blocklist.` }); + return; + } + + const entries = await ctx.storage.listBlocklistEntries(guildId); + const match = entries.find((entry) => entry.domain === domain); + if (!match) { + await interaction.reply({ + ephemeral: true, + content: `**${domain}** is not on the blocklist.`, + }); + return; + } + await ctx.storage.removeBlocklistEntry(guildId, match.id); + await interaction.reply({ + ephemeral: true, + content: `Removed **${domain}** from the blocklist.`, + }); +} + +export const blocklistCommand: SlashCommand = { name: 'blocklist', builder, execute }; diff --git a/packages/discord-bot/src/commands/config.ts b/packages/discord-bot/src/commands/config.ts new file mode 100644 index 0000000..753e612 --- /dev/null +++ b/packages/discord-bot/src/commands/config.ts @@ -0,0 +1,74 @@ +import { + AttachmentBuilder, + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import { + exportGuildConfigBundle, + importGuildConfigBundle, + parseConfigBundle, +} from '@antilink-guard/storage'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +export type FetchLike = (url: string) => Promise<{ text(): Promise }>; + +const builder = new SlashCommandBuilder() + .setName('config') + .setDescription("Export or import this server's AntiLink Guard configuration") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => + sub.setName('export').setDescription('Export the current configuration as a JSON file'), + ) + .addSubcommand((sub) => + sub + .setName('import') + .setDescription('Import a configuration JSON file exported from this bot') + .addAttachmentOption((opt) => + opt.setName('file').setDescription('Config bundle JSON file').setRequired(true), + ), + ); + +export function createConfigCommand(fetchImpl: FetchLike = fetch): SlashCommand { + async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(true); + + if (sub === 'export') { + const bundle = await exportGuildConfigBundle(ctx.storage, guildId); + const attachment = new AttachmentBuilder( + Buffer.from(JSON.stringify(bundle, null, 2), 'utf8'), + { + name: `antilink-config-${guildId}.json`, + }, + ); + await interaction.reply({ ephemeral: true, files: [attachment] }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + const attachment = interaction.options.getAttachment('file', true); + + try { + const response = await fetchImpl(attachment.url); + const raw = JSON.parse(await response.text()); + const bundle = parseConfigBundle(raw); + // Always scope the import to the guild running the command, regardless of what + // guildId the uploaded file claims - never let an import write another guild's config. + const scopedBundle = { ...bundle, guildConfig: { ...bundle.guildConfig, guildId } }; + await importGuildConfigBundle(ctx.storage, scopedBundle); + await interaction.editReply('Configuration imported successfully.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await interaction.editReply(`Import failed: invalid config file (${message}).`); + } + } + + return { name: 'config', builder, execute }; +} + +export const configCommand: SlashCommand = createConfigCommand(); diff --git a/packages/discord-bot/src/commands/index.ts b/packages/discord-bot/src/commands/index.ts new file mode 100644 index 0000000..09ec10d --- /dev/null +++ b/packages/discord-bot/src/commands/index.ts @@ -0,0 +1,36 @@ +import { antilinkCommand } from './antilink.js'; +import { allowlistCommand } from './allowlist.js'; +import { blocklistCommand } from './blocklist.js'; +import { invitesCommand } from './invites.js'; +import { logsCommand } from './logs.js'; +import { testlinkCommand } from './testlink.js'; +import { configCommand } from './config.js'; +import type { SlashCommand } from './types.js'; + +export const allCommands: SlashCommand[] = [ + antilinkCommand, + allowlistCommand, + blocklistCommand, + invitesCommand, + logsCommand, + testlinkCommand, + configCommand, +]; + +export const commandsByName: ReadonlyMap = new Map( + allCommands.map((command) => [command.name, command]), +); + +export function getCommandJSONBodies(): unknown[] { + return allCommands.map((command) => command.builder.toJSON()); +} + +export * from './types.js'; +export { antilinkCommand } from './antilink.js'; +export { allowlistCommand } from './allowlist.js'; +export { blocklistCommand } from './blocklist.js'; +export { invitesCommand, parseInviteCode } from './invites.js'; +export { logsCommand } from './logs.js'; +export { testlinkCommand } from './testlink.js'; +export { configCommand, createConfigCommand } from './config.js'; +export type { FetchLike } from './config.js'; diff --git a/packages/discord-bot/src/commands/invites.ts b/packages/discord-bot/src/commands/invites.ts new file mode 100644 index 0000000..983c26c --- /dev/null +++ b/packages/discord-bot/src/commands/invites.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto'; +import { + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +const INVITE_URL_PREFIX = + /^(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\//i; + +export function parseInviteCode(input: string): string { + return input.trim().replace(INVITE_URL_PREFIX, '').replace(/\/$/, ''); +} + +const builder = new SlashCommandBuilder() + .setName('invites') + .setDescription('Manage Discord invite link handling') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => + sub + .setName('allow') + .setDescription('Allow a specific Discord invite') + .addStringOption((opt) => + opt.setName('invite').setDescription('Invite URL or code').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('block-all') + .setDescription('Block all Discord invites except explicitly allowed ones') + .addBooleanOption((opt) => + opt.setName('enabled').setDescription('Enable (default) or disable invite blocking'), + ), + ); + +async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(true); + + if (sub === 'allow') { + const inviteCode = parseInviteCode(interaction.options.getString('invite', true)); + await ctx.storage.addInviteRule({ + id: randomUUID(), + guildId, + inviteCode, + addedBy: interaction.user.id, + createdAt: new Date(), + }); + await interaction.reply({ + ephemeral: true, + content: `Allowed invite **${inviteCode}**.`, + }); + return; + } + + const enabled = interaction.options.getBoolean('enabled') ?? true; + const existing = (await ctx.storage.getGuildConfig(guildId)) ?? createDefaultGuildConfig(guildId); + await ctx.storage.upsertGuildConfig({ + ...existing, + blockAllInvites: enabled, + updatedAt: new Date(), + }); + await interaction.reply({ + ephemeral: true, + content: `Blocking all invites (except allowed ones) is now **${enabled ? 'on' : 'off'}**.`, + }); +} + +export const invitesCommand: SlashCommand = { name: 'invites', builder, execute }; diff --git a/packages/discord-bot/src/commands/logs.ts b/packages/discord-bot/src/commands/logs.ts new file mode 100644 index 0000000..56e3db1 --- /dev/null +++ b/packages/discord-bot/src/commands/logs.ts @@ -0,0 +1,46 @@ +import { + ChannelType, + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +const builder = new SlashCommandBuilder() + .setName('logs') + .setDescription('Configure the moderation log channel') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => + sub + .setName('set-channel') + .setDescription('Set where moderation actions are logged') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Text channel to post mod-log messages to') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ), + ); + +async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const channel = interaction.options.getChannel('channel', true); + const existing = (await ctx.storage.getGuildConfig(guildId)) ?? createDefaultGuildConfig(guildId); + await ctx.storage.upsertGuildConfig({ + ...existing, + logChannelId: channel.id, + updatedAt: new Date(), + }); + await interaction.reply({ + ephemeral: true, + content: `Moderation log channel set to <#${channel.id}>.`, + }); +} + +export const logsCommand: SlashCommand = { name: 'logs', builder, execute }; diff --git a/packages/discord-bot/src/commands/testlink.ts b/packages/discord-bot/src/commands/testlink.ts new file mode 100644 index 0000000..a6dda42 --- /dev/null +++ b/packages/discord-bot/src/commands/testlink.ts @@ -0,0 +1,52 @@ +import { SlashCommandBuilder, type ChatInputCommandInteraction } from 'discord.js'; +import { classifyLink, extractLinks } from '@antilink-guard/core'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; +import { buildPolicyConfig } from '../policy-from-config.js'; +import type { BotContext } from '../context.js'; +import { requireGuild } from './types.js'; +import type { SlashCommand } from './types.js'; + +const builder = new SlashCommandBuilder() + .setName('testlink') + .setDescription('Check how a URL would be classified without posting it') + .addStringOption((opt) => opt.setName('url').setDescription('URL to test').setRequired(true)); + +async function execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise { + const guildId = await requireGuild(interaction); + if (!guildId) return; + + const url = interaction.options.getString('url', true); + const [link] = extractLinks(url); + if (!link) { + await interaction.reply({ ephemeral: true, content: `Could not parse \`${url}\` as a URL.` }); + return; + } + + const [guildConfig, allowlist, blocklist, inviteRules] = await Promise.all([ + ctx.storage.getGuildConfig(guildId), + ctx.storage.listAllowlistEntries(guildId), + ctx.storage.listBlocklistEntries(guildId), + ctx.storage.listInviteRules(guildId), + ]); + + const policy = buildPolicyConfig({ + guildConfig: guildConfig ?? createDefaultGuildConfig(guildId), + allowlist, + blocklist, + inviteRules, + }); + + const classification = classifyLink(link, policy, url); + const reasons = classification.reasons.length > 0 ? classification.reasons.join(', ') : 'none'; + + await interaction.reply({ + ephemeral: true, + content: [ + `**URL:** ${classification.link.normalizedUrl}`, + `**Score:** ${classification.score}`, + `**Reasons:** ${reasons}`, + ].join('\n'), + }); +} + +export const testlinkCommand: SlashCommand = { name: 'testlink', builder, execute }; diff --git a/packages/discord-bot/src/commands/types.ts b/packages/discord-bot/src/commands/types.ts new file mode 100644 index 0000000..3caaf17 --- /dev/null +++ b/packages/discord-bot/src/commands/types.ts @@ -0,0 +1,21 @@ +import type { ChatInputCommandInteraction } from 'discord.js'; +import type { BotContext } from '../context.js'; + +export interface SlashCommand { + name: string; + builder: { toJSON(): unknown }; + execute(interaction: ChatInputCommandInteraction, ctx: BotContext): Promise; +} + +export async function requireGuild( + interaction: ChatInputCommandInteraction, +): Promise { + if (!interaction.guildId) { + await interaction.reply({ + content: 'This command can only be used in a server.', + ephemeral: true, + }); + return undefined; + } + return interaction.guildId; +} diff --git a/packages/discord-bot/src/context.ts b/packages/discord-bot/src/context.ts new file mode 100644 index 0000000..7e46397 --- /dev/null +++ b/packages/discord-bot/src/context.ts @@ -0,0 +1,7 @@ +import type { StorageAdapter } from '@antilink-guard/storage'; +import type { TokenBucketRateLimiter } from './rate-limit.js'; + +export interface BotContext { + storage: StorageAdapter; + moderationRateLimiter: TokenBucketRateLimiter; +} diff --git a/packages/discord-bot/src/events/interaction-create.ts b/packages/discord-bot/src/events/interaction-create.ts new file mode 100644 index 0000000..6493b96 --- /dev/null +++ b/packages/discord-bot/src/events/interaction-create.ts @@ -0,0 +1,30 @@ +import type { Interaction } from 'discord.js'; +import { commandsByName } from '../commands/index.js'; +import type { BotContext } from '../context.js'; +import { logger } from '../logger.js'; + +export async function handleInteractionCreate( + interaction: Interaction, + ctx: BotContext, +): Promise { + if (!interaction.isChatInputCommand()) return; + + const command = commandsByName.get(interaction.commandName); + if (!command) return; + + try { + await command.execute(interaction, ctx); + } catch (error) { + logger.error({ err: error, command: interaction.commandName }, 'command execution failed'); + const payload = { content: 'Something went wrong running that command.', ephemeral: true }; + try { + if (interaction.deferred || interaction.replied) { + await interaction.followUp(payload); + } else { + await interaction.reply(payload); + } + } catch (replyError) { + logger.error({ err: replyError }, 'failed to send error reply for a failed command'); + } + } +} diff --git a/packages/discord-bot/src/events/message-create.ts b/packages/discord-bot/src/events/message-create.ts new file mode 100644 index 0000000..fbad917 --- /dev/null +++ b/packages/discord-bot/src/events/message-create.ts @@ -0,0 +1,63 @@ +import type { Message } from 'discord.js'; +import { evaluateMessage } from '@antilink-guard/core'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; +import { buildPolicyConfig } from '../policy-from-config.js'; +import { enforceScanResult } from '../moderation/enforce.js'; +import { sendModLog } from '../moderation/mod-log.js'; +import type { BotContext } from '../context.js'; +import { logger } from '../logger.js'; + +const RATE_LIMIT_KEY_PREFIX = 'guild:'; + +export async function handleMessageCreate(message: Message, ctx: BotContext): Promise { + if (message.author.bot) return; + if (!message.inGuild()) return; + + const guildId = message.guildId; + + try { + const [guildConfig, allowlist, blocklist, inviteRules] = await Promise.all([ + ctx.storage.getGuildConfig(guildId), + ctx.storage.listAllowlistEntries(guildId), + ctx.storage.listBlocklistEntries(guildId), + ctx.storage.listInviteRules(guildId), + ]); + + const resolvedGuildConfig = guildConfig ?? createDefaultGuildConfig(guildId); + if (!resolvedGuildConfig.enabled) return; + + const policy = buildPolicyConfig({ + guildConfig: resolvedGuildConfig, + allowlist, + blocklist, + inviteRules, + }); + + const result = evaluateMessage( + message.content, + { + guildId, + channelId: message.channelId, + authorId: message.author.id, + authorRoleIds: message.member?.roles.cache.map((role) => role.id) ?? [], + mentionCount: message.mentions.users.size + message.mentions.roles.size, + }, + policy, + ); + + if (result.verdict === 'ALLOW' || result.bypassed) return; + + if (!ctx.moderationRateLimiter.tryConsume(`${RATE_LIMIT_KEY_PREFIX}${guildId}`)) { + logger.warn({ guildId }, 'moderation rate limit exceeded, skipping enforcement'); + return; + } + + const { auditEntry } = await enforceScanResult(message, result, ctx.storage); + + if (resolvedGuildConfig.logChannelId) { + await sendModLog(message.client, resolvedGuildConfig.logChannelId, auditEntry); + } + } catch (error) { + logger.error({ err: error, guildId }, 'failed to process message for moderation'); + } +} diff --git a/packages/discord-bot/src/index.ts b/packages/discord-bot/src/index.ts new file mode 100644 index 0000000..e8e2200 --- /dev/null +++ b/packages/discord-bot/src/index.ts @@ -0,0 +1,19 @@ +export { createBot } from './bot.js'; +export type { CreateBotOptions, ModerationRateLimitOptions } from './bot.js'; +export { registerCommands } from './register-commands.js'; +export type { RegisterCommandsOptions } from './register-commands.js'; +export { TokenBucketRateLimiter } from './rate-limit.js'; +export { buildPolicyConfig } from './policy-from-config.js'; +export type { PolicyInputs } from './policy-from-config.js'; +export { enforceScanResult, DEFAULT_TIMEOUT_DURATION_MS } from './moderation/enforce.js'; +export type { + EnforceableMessage, + EnforcementOutcome, + EnforceOptions, +} from './moderation/enforce.js'; +export { buildModLogEmbed, sendModLog } from './moderation/mod-log.js'; +export type { ModLogChannelSource } from './moderation/mod-log.js'; +export { handleMessageCreate } from './events/message-create.js'; +export { handleInteractionCreate } from './events/interaction-create.js'; +export type { BotContext } from './context.js'; +export * from './commands/index.js'; diff --git a/packages/discord-bot/src/logger.ts b/packages/discord-bot/src/logger.ts new file mode 100644 index 0000000..e725bbf --- /dev/null +++ b/packages/discord-bot/src/logger.ts @@ -0,0 +1,11 @@ +import pino from 'pino'; + +/** + * Structured JSON logs by design (the standard pino library pattern) - pipe + * through `pino-pretty` for a human-readable terminal, e.g.: + * node dist/bot.js | npx pino-pretty + */ +export const logger = pino({ + name: 'antilink-guard', + level: process.env.LOG_LEVEL ?? 'info', +}); diff --git a/packages/discord-bot/src/moderation/enforce.ts b/packages/discord-bot/src/moderation/enforce.ts new file mode 100644 index 0000000..7f3e73b --- /dev/null +++ b/packages/discord-bot/src/moderation/enforce.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto'; +import type { ModerationActionType, ScanResult } from '@antilink-guard/core'; +import type { + AuditLogEntry, + ModerationActionRecord, + StorageAdapter, +} from '@antilink-guard/storage'; +import { logger } from '../logger.js'; + +export const DEFAULT_TIMEOUT_DURATION_MS = 10 * 60 * 1000; + +/** + * The minimal shape enforce.ts needs from a discord.js Message. Depending on + * this narrow interface (rather than the full discord.js Message class) keeps + * this module testable with plain fakes instead of a live Discord connection. + */ +export interface EnforceableMessage { + guildId: string; + channelId: string; + author: { id: string }; + deletable: boolean; + delete(): Promise; + member: { + moderatable: boolean; + timeout(durationMs: number, reason?: string): Promise; + } | null; +} + +export interface EnforceOptions { + timeoutDurationMs?: number; +} + +export interface EnforcementOutcome { + actionTaken: ModerationActionType; + auditEntry: AuditLogEntry; +} + +export async function enforceScanResult( + message: EnforceableMessage, + result: ScanResult, + storage: StorageAdapter, + options: EnforceOptions = {}, +): Promise { + const timeoutDurationMs = options.timeoutDurationMs ?? DEFAULT_TIMEOUT_DURATION_MS; + const topLink = result.matchedLinks[0]?.link; + const reasonText = result.reasons.length > 0 ? result.reasons.join(', ') : 'policy match'; + + let actionTaken: ModerationActionType = 'NONE'; + + if (result.action === 'DELETE' || result.action === 'TIMEOUT') { + if (message.deletable) { + try { + await message.delete(); + actionTaken = 'DELETE'; + } catch (error) { + logger.warn({ err: error, guildId: message.guildId }, 'failed to delete message'); + } + } else { + logger.warn( + { guildId: message.guildId, channelId: message.channelId }, + 'missing permission to delete message, skipping', + ); + } + } + + if (result.action === 'TIMEOUT') { + if (message.member?.moderatable) { + try { + await message.member.timeout(timeoutDurationMs, reasonText); + actionTaken = 'TIMEOUT'; + } catch (error) { + logger.warn({ err: error, guildId: message.guildId }, 'failed to time out member'); + } + } else { + logger.warn( + { guildId: message.guildId, userId: message.author.id }, + 'missing permission or role hierarchy to time out member, skipping', + ); + } + } + + if ((result.action === 'LOG' || result.action === 'WARN') && actionTaken === 'NONE') { + actionTaken = result.action; + } + + const auditEntry: AuditLogEntry = { + id: randomUUID(), + guildId: message.guildId, + channelId: message.channelId, + userId: message.author.id, + normalizedUrl: topLink?.normalizedUrl, + hostname: topLink?.hostname, + verdict: result.verdict, + reasons: result.reasons, + score: result.score, + action: actionTaken, + createdAt: new Date(), + }; + await storage.addAuditLogEntry(auditEntry); + + const moderationAction: ModerationActionRecord = { + id: randomUUID(), + guildId: message.guildId, + auditLogEntryId: auditEntry.id, + userId: message.author.id, + channelId: message.channelId, + actionType: actionTaken, + reason: reasonText, + createdAt: new Date(), + }; + await storage.addModerationAction(moderationAction); + + return { actionTaken, auditEntry }; +} diff --git a/packages/discord-bot/src/moderation/mod-log.ts b/packages/discord-bot/src/moderation/mod-log.ts new file mode 100644 index 0000000..2b3708c --- /dev/null +++ b/packages/discord-bot/src/moderation/mod-log.ts @@ -0,0 +1,72 @@ +import { EmbedBuilder } from 'discord.js'; +import type { Verdict } from '@antilink-guard/core'; +import type { AuditLogEntry } from '@antilink-guard/storage'; +import { logger } from '../logger.js'; + +const DEFAULT_COLOR = 0x95a5a6; + +const VERDICT_COLOR: Record = { + ALLOW: 0x2ecc71, + WARN: 0xf1c40f, + BLOCK: 0xe67e22, + QUARANTINE: 0xe74c3c, +}; + +export function buildModLogEmbed(entry: AuditLogEntry): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle('AntiLink Guard - action taken') + .setColor(VERDICT_COLOR[entry.verdict] ?? DEFAULT_COLOR) + .addFields( + { name: 'User', value: `<@${entry.userId}>`, inline: true }, + { name: 'Channel', value: `<#${entry.channelId}>`, inline: true }, + { name: 'Action', value: entry.action, inline: true }, + { name: 'Verdict', value: entry.verdict, inline: true }, + { name: 'Score', value: String(entry.score), inline: true }, + { name: 'Reasons', value: entry.reasons.length > 0 ? entry.reasons.join(', ') : 'none' }, + ) + .setTimestamp(entry.createdAt); + + if (entry.normalizedUrl) { + embed.addFields({ name: 'URL', value: entry.normalizedUrl }); + } + + return embed; +} + +interface SendableChannel { + send(payload: { embeds: EmbedBuilder[] }): Promise; +} + +function isSendableChannel(channel: unknown): channel is SendableChannel { + if (typeof channel !== 'object' || channel === null) return false; + const candidate = channel as { isTextBased?: unknown; send?: unknown }; + return ( + typeof candidate.isTextBased === 'function' && + (candidate.isTextBased as () => boolean)() === true && + typeof candidate.send === 'function' + ); +} + +/** The minimal shape needed from a discord.js Client to post a mod-log message. */ +export interface ModLogChannelSource { + channels: { + fetch(channelId: string): Promise; + }; +} + +export async function sendModLog( + client: ModLogChannelSource, + logChannelId: string, + entry: AuditLogEntry, +): Promise { + try { + const channel = await client.channels.fetch(logChannelId); + if (!isSendableChannel(channel)) { + logger.warn({ logChannelId }, 'configured log channel is missing or not text-based'); + return; + } + await channel.send({ embeds: [buildModLogEmbed(entry)] }); + } catch (error) { + logger.warn({ err: error, logChannelId }, 'failed to send mod-log message'); + } +} diff --git a/packages/discord-bot/src/policy-from-config.ts b/packages/discord-bot/src/policy-from-config.ts new file mode 100644 index 0000000..c99264f --- /dev/null +++ b/packages/discord-bot/src/policy-from-config.ts @@ -0,0 +1,37 @@ +import type { PolicyConfig } from '@antilink-guard/core'; +import type { + AllowlistEntry, + BlocklistEntry, + GuildConfig, + InviteRule, +} from '@antilink-guard/storage'; + +export interface PolicyInputs { + guildConfig: GuildConfig; + allowlist: AllowlistEntry[]; + blocklist: BlocklistEntry[]; + inviteRules: InviteRule[]; +} + +/** + * No known-phishing domain list is populated here by design - same principle as + * @antilink-guard/core: this framework never claims a threat-intel database it + * doesn't have. Blocklisted domains come entirely from what a guild's admins add. + */ +export function buildPolicyConfig(inputs: PolicyInputs): PolicyConfig { + const { guildConfig, allowlist, blocklist, inviteRules } = inputs; + + return { + enabled: guildConfig.enabled, + mode: guildConfig.mode, + bypassRoleIds: guildConfig.bypassRoleIds, + bypassUserIds: guildConfig.bypassUserIds, + domainAllowlist: allowlist.map((entry) => entry.domain), + domainBlocklist: blocklist.map((entry) => entry.domain), + inviteAllowlist: inviteRules.map((rule) => rule.inviteCode), + blockAllInvites: guildConfig.blockAllInvites, + requireAllowlist: guildConfig.requireAllowlist, + flagUnknownDomains: guildConfig.flagUnknownDomains, + massMentionThreshold: guildConfig.massMentionThreshold, + }; +} diff --git a/packages/discord-bot/src/rate-limit.ts b/packages/discord-bot/src/rate-limit.ts new file mode 100644 index 0000000..69921ea --- /dev/null +++ b/packages/discord-bot/src/rate-limit.ts @@ -0,0 +1,43 @@ +interface Bucket { + tokens: number; + lastRefillMs: number; +} + +/** + * A simple per-key token bucket. Used to cap how many moderation actions + * (message deletes, timeouts, mod-log posts) the bot will perform in a burst, + * so a raid or spam wave can't drive the bot into Discord's own API rate limits. + */ +export class TokenBucketRateLimiter { + private buckets = new Map(); + + constructor( + private readonly capacity: number, + private readonly refillPerSecond: number, + private readonly now: () => number = Date.now, + ) { + if (capacity <= 0) throw new Error('capacity must be positive'); + if (refillPerSecond <= 0) throw new Error('refillPerSecond must be positive'); + } + + tryConsume(key: string, cost = 1): boolean { + const nowMs = this.now(); + const bucket = this.buckets.get(key) ?? { tokens: this.capacity, lastRefillMs: nowMs }; + + const elapsedSeconds = Math.max(0, (nowMs - bucket.lastRefillMs) / 1000); + const refilled = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond); + + if (refilled < cost) { + this.buckets.set(key, { tokens: refilled, lastRefillMs: nowMs }); + return false; + } + + this.buckets.set(key, { tokens: refilled - cost, lastRefillMs: nowMs }); + return true; + } + + reset(key?: string): void { + if (key) this.buckets.delete(key); + else this.buckets.clear(); + } +} diff --git a/packages/discord-bot/src/register-commands.ts b/packages/discord-bot/src/register-commands.ts new file mode 100644 index 0000000..1fe2447 --- /dev/null +++ b/packages/discord-bot/src/register-commands.ts @@ -0,0 +1,24 @@ +import { REST, Routes } from 'discord.js'; +import { getCommandJSONBodies } from './commands/index.js'; +import { logger } from './logger.js'; + +export interface RegisterCommandsOptions { + token: string; + clientId: string; + /** If provided, registers commands to a single guild (updates instantly) instead of globally (~1hr propagation). */ + guildId?: string; +} + +export async function registerCommands(options: RegisterCommandsOptions): Promise { + const rest = new REST().setToken(options.token); + const body = getCommandJSONBodies(); + const route = options.guildId + ? Routes.applicationGuildCommands(options.clientId, options.guildId) + : Routes.applicationCommands(options.clientId); + + await rest.put(route, { body }); + logger.info( + { count: body.length, scope: options.guildId ? `guild:${options.guildId}` : 'global' }, + 'registered slash commands', + ); +} diff --git a/packages/discord-bot/test/commands.test.ts b/packages/discord-bot/test/commands.test.ts new file mode 100644 index 0000000..83b8f03 --- /dev/null +++ b/packages/discord-bot/test/commands.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { PermissionFlagsBits } from 'discord.js'; +import { MemoryStorageAdapter, createDefaultGuildConfig } from '@antilink-guard/storage'; +import { TokenBucketRateLimiter } from '../src/rate-limit.js'; +import type { BotContext } from '../src/context.js'; +import { + allCommands, + allowlistCommand, + antilinkCommand, + blocklistCommand, + createConfigCommand, + invitesCommand, + logsCommand, + parseInviteCode, + testlinkCommand, +} from '../src/commands/index.js'; + +async function makeContext(): Promise { + const storage = new MemoryStorageAdapter(); + await storage.init(); + return { storage, moderationRateLimiter: new TokenBucketRateLimiter(10, 10) }; +} + +interface FakeInteractionOptions { + guildId?: string | null; + subcommand?: string; + strings?: Record; + booleans?: Record; + channel?: { id: string } | null; + attachment?: { url: string } | null; + userId?: string; +} + +function makeInteraction(opts: FakeInteractionOptions = {}): ChatInputCommandInteraction { + const reply = vi.fn().mockResolvedValue(undefined); + const deferReply = vi.fn().mockResolvedValue(undefined); + const editReply = vi.fn().mockResolvedValue(undefined); + + const fake = { + guildId: opts.guildId === undefined ? 'guild-1' : opts.guildId, + user: { id: opts.userId ?? 'user-1' }, + deferred: false, + replied: false, + reply, + deferReply, + editReply, + options: { + getSubcommand: () => opts.subcommand ?? '', + getString: (name: string) => opts.strings?.[name] ?? null, + getBoolean: (name: string) => opts.booleans?.[name] ?? null, + getChannel: () => opts.channel ?? null, + getAttachment: () => opts.attachment ?? null, + }, + }; + + return fake as unknown as ChatInputCommandInteraction; +} + +describe('slash command JSON shapes', () => { + it('defines exactly the required top-level commands', () => { + const names = allCommands.map((c) => c.name).sort(); + expect(names).toEqual( + ['allowlist', 'antilink', 'blocklist', 'config', 'invites', 'logs', 'testlink'].sort(), + ); + }); + + it('restricts admin commands to Manage Server by default', () => { + for (const command of allCommands) { + if (command.name === 'testlink') continue; + const json = command.builder.toJSON() as { default_member_permissions?: string }; + expect(json.default_member_permissions).toBe(String(PermissionFlagsBits.ManageGuild)); + } + }); + + it('leaves testlink available to everyone', () => { + const json = testlinkCommand.builder.toJSON() as { default_member_permissions?: string | null }; + expect(json.default_member_permissions).toBeFalsy(); + }); + + it('antilink exposes status/enable/disable/mode subcommands', () => { + const json = antilinkCommand.builder.toJSON() as { options?: { name: string }[] }; + const subNames = json.options?.map((o) => o.name) ?? []; + expect(subNames).toEqual(['status', 'enable', 'disable', 'mode']); + }); + + it('allowlist and blocklist both expose add/remove with a required domain option', () => { + for (const command of [allowlistCommand, blocklistCommand]) { + const json = command.builder.toJSON() as { + options?: { name: string; options?: { name: string; required?: boolean }[] }[]; + }; + const subNames = json.options?.map((o) => o.name) ?? []; + expect(subNames).toEqual(['add', 'remove']); + for (const sub of json.options ?? []) { + expect(sub.options?.[0]?.name).toBe('domain'); + expect(sub.options?.[0]?.required).toBe(true); + } + } + }); + + it('invites exposes allow and block-all', () => { + const json = invitesCommand.builder.toJSON() as { options?: { name: string }[] }; + expect(json.options?.map((o) => o.name)).toEqual(['allow', 'block-all']); + }); + + it('logs exposes set-channel with a required channel option', () => { + const json = logsCommand.builder.toJSON() as { + options?: { name: string; options?: { name: string; required?: boolean }[] }[]; + }; + expect(json.options?.[0]?.name).toBe('set-channel'); + expect(json.options?.[0]?.options?.[0]?.name).toBe('channel'); + expect(json.options?.[0]?.options?.[0]?.required).toBe(true); + }); + + it('config exposes export and import, with import requiring a file attachment', () => { + const json = createConfigCommand().builder.toJSON() as { + options?: { name: string; options?: { name: string; type: number; required?: boolean }[] }[]; + }; + const subNames = json.options?.map((o) => o.name) ?? []; + expect(subNames).toEqual(['export', 'import']); + const importSub = json.options?.find((o) => o.name === 'import'); + expect(importSub?.options?.[0]?.name).toBe('file'); + expect(importSub?.options?.[0]?.required).toBe(true); + }); +}); + +describe('parseInviteCode', () => { + it('extracts the code from a full discord.gg URL', () => { + expect(parseInviteCode('https://discord.gg/abc123')).toBe('abc123'); + }); + + it('extracts the code from a discord.com/invite URL', () => { + expect(parseInviteCode('https://discord.com/invite/xyz789')).toBe('xyz789'); + }); + + it('passes through a bare invite code unchanged', () => { + expect(parseInviteCode('bare-code')).toBe('bare-code'); + }); +}); + +describe('command execute() behavior', () => { + it('antilink status reports the default config for a fresh guild', async () => { + const ctx = await makeContext(); + const interaction = makeInteraction({ subcommand: 'status' }); + await antilinkCommand.execute(interaction, ctx); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Enabled:** true') }), + ); + }); + + it('antilink enable/disable persists the change', async () => { + const ctx = await makeContext(); + await antilinkCommand.execute(makeInteraction({ subcommand: 'disable' }), ctx); + const config = await ctx.storage.getGuildConfig('guild-1'); + expect(config?.enabled).toBe(false); + }); + + it('antilink mode "block" is stored internally as the "delete" enforcement mode', async () => { + const ctx = await makeContext(); + await antilinkCommand.execute( + makeInteraction({ subcommand: 'mode', strings: { value: 'block' } }), + ctx, + ); + const config = await ctx.storage.getGuildConfig('guild-1'); + expect(config?.mode).toBe('delete'); + }); + + it('antilink mode "warn" and "log" map directly', async () => { + const ctx = await makeContext(); + await antilinkCommand.execute( + makeInteraction({ subcommand: 'mode', strings: { value: 'warn' } }), + ctx, + ); + expect((await ctx.storage.getGuildConfig('guild-1'))?.mode).toBe('warn'); + + await antilinkCommand.execute( + makeInteraction({ subcommand: 'mode', strings: { value: 'log' } }), + ctx, + ); + expect((await ctx.storage.getGuildConfig('guild-1'))?.mode).toBe('log'); + }); + + it('refuses to run any admin command outside a guild', async () => { + const ctx = await makeContext(); + const interaction = makeInteraction({ guildId: null, subcommand: 'status' }); + await antilinkCommand.execute(interaction, ctx); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('only be used in a server') }), + ); + }); + + it('allowlist add then remove round-trips a domain', async () => { + const ctx = await makeContext(); + await allowlistCommand.execute( + makeInteraction({ subcommand: 'add', strings: { domain: 'example.com' } }), + ctx, + ); + expect(await ctx.storage.listAllowlistEntries('guild-1')).toHaveLength(1); + + await allowlistCommand.execute( + makeInteraction({ subcommand: 'remove', strings: { domain: 'example.com' } }), + ctx, + ); + expect(await ctx.storage.listAllowlistEntries('guild-1')).toHaveLength(0); + }); + + it('allowlist remove replies gracefully when the domain is not present', async () => { + const ctx = await makeContext(); + const interaction = makeInteraction({ subcommand: 'remove', strings: { domain: 'nope.com' } }); + await allowlistCommand.execute(interaction, ctx); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('not on the allowlist') }), + ); + }); + + it('blocklist add then remove round-trips a domain', async () => { + const ctx = await makeContext(); + await blocklistCommand.execute( + makeInteraction({ subcommand: 'add', strings: { domain: 'bad.com' } }), + ctx, + ); + expect(await ctx.storage.listBlocklistEntries('guild-1')).toHaveLength(1); + + await blocklistCommand.execute( + makeInteraction({ subcommand: 'remove', strings: { domain: 'bad.com' } }), + ctx, + ); + expect(await ctx.storage.listBlocklistEntries('guild-1')).toHaveLength(0); + }); + + it('invites allow stores a parsed invite code', async () => { + const ctx = await makeContext(); + await invitesCommand.execute( + makeInteraction({ subcommand: 'allow', strings: { invite: 'https://discord.gg/abc123' } }), + ctx, + ); + const rules = await ctx.storage.listInviteRules('guild-1'); + expect(rules[0]?.inviteCode).toBe('abc123'); + }); + + it('invites block-all defaults to enabling when no boolean is given', async () => { + const ctx = await makeContext(); + await invitesCommand.execute(makeInteraction({ subcommand: 'block-all' }), ctx); + expect((await ctx.storage.getGuildConfig('guild-1'))?.blockAllInvites).toBe(true); + }); + + it('invites block-all can be explicitly disabled', async () => { + const ctx = await makeContext(); + await invitesCommand.execute( + makeInteraction({ subcommand: 'block-all', booleans: { enabled: false } }), + ctx, + ); + expect((await ctx.storage.getGuildConfig('guild-1'))?.blockAllInvites).toBe(false); + }); + + it('logs set-channel persists the channel id', async () => { + const ctx = await makeContext(); + await logsCommand.execute( + makeInteraction({ subcommand: 'set-channel', channel: { id: 'channel-99' } }), + ctx, + ); + expect((await ctx.storage.getGuildConfig('guild-1'))?.logChannelId).toBe('channel-99'); + }); + + it('testlink reports a shortener domain for a bit.ly URL', async () => { + const ctx = await makeContext(); + const interaction = makeInteraction({ strings: { url: 'https://bit.ly/abc' } }); + await testlinkCommand.execute(interaction, ctx); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('URL_SHORTENER') }), + ); + }); + + it('testlink handles unparseable input gracefully', async () => { + const ctx = await makeContext(); + const interaction = makeInteraction({ strings: { url: 'not a url ://' } }); + await testlinkCommand.execute(interaction, ctx); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Could not parse') }), + ); + }); + + it('config export attaches a JSON file scoped to the current guild', async () => { + const ctx = await makeContext(); + const command = createConfigCommand(); + const interaction = makeInteraction({ subcommand: 'export' }); + await command.execute(interaction, ctx); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ files: expect.any(Array) }), + ); + }); + + it('config import forces the imported guildId to the current guild, ignoring the file', async () => { + const ctx = await makeContext(); + const foreignBundle = { + version: 1, + guildConfig: { ...createDefaultGuildConfig('some-other-guild'), mode: 'timeout' }, + allowlist: [], + blocklist: [], + inviteRules: [], + }; + const fetchImpl = vi + .fn() + .mockResolvedValue({ text: async () => JSON.stringify(foreignBundle) }); + const command = createConfigCommand(fetchImpl); + + const interaction = makeInteraction({ + subcommand: 'import', + attachment: { url: 'https://cdn.discord.com/fake.json' }, + }); + await command.execute(interaction, ctx); + + expect(await ctx.storage.getGuildConfig('some-other-guild')).toBeUndefined(); + const ownConfig = await ctx.storage.getGuildConfig('guild-1'); + expect(ownConfig?.mode).toBe('timeout'); + }); + + it('config import replies with an error for an invalid file', async () => { + const ctx = await makeContext(); + const fetchImpl = vi.fn().mockResolvedValue({ text: async () => 'not valid json' }); + const command = createConfigCommand(fetchImpl); + + const interaction = makeInteraction({ + subcommand: 'import', + attachment: { url: 'https://cdn.discord.com/fake.json' }, + }); + await command.execute(interaction, ctx); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Import failed')); + }); +}); diff --git a/packages/discord-bot/test/enforce.test.ts b/packages/discord-bot/test/enforce.test.ts new file mode 100644 index 0000000..9d19a62 --- /dev/null +++ b/packages/discord-bot/test/enforce.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ScanResult } from '@antilink-guard/core'; +import { MemoryStorageAdapter } from '@antilink-guard/storage'; +import { enforceScanResult, type EnforceableMessage } from '../src/moderation/enforce.js'; + +function makeMessage(overrides: Partial = {}): EnforceableMessage { + return { + guildId: 'guild-1', + channelId: 'channel-1', + author: { id: 'user-1' }, + deletable: true, + delete: vi.fn().mockResolvedValue(undefined), + member: { + moderatable: true, + timeout: vi.fn().mockResolvedValue(undefined), + }, + ...overrides, + }; +} + +function makeResult(overrides: Partial = {}): ScanResult { + return { + verdict: 'BLOCK', + action: 'DELETE', + score: 50, + reasons: ['BLOCKLIST_MATCH'], + matchedLinks: [], + bypassed: false, + ...overrides, + }; +} + +describe('enforceScanResult', () => { + it('deletes the message and records DELETE for a BLOCK verdict in delete mode', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + + const { actionTaken } = await enforceScanResult(message, makeResult(), storage); + + expect(actionTaken).toBe('DELETE'); + expect(message.delete).toHaveBeenCalledOnce(); + const audits = await storage.listAuditLogEntries('guild-1'); + expect(audits[0]?.action).toBe('DELETE'); + }); + + it('does not call delete() when the bot lacks permission (message.deletable is false)', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage({ deletable: false }); + + const { actionTaken } = await enforceScanResult(message, makeResult(), storage); + + expect(actionTaken).toBe('NONE'); + expect(message.delete).not.toHaveBeenCalled(); + }); + + it('deletes and times out for a QUARANTINE verdict with a TIMEOUT action', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + const result = makeResult({ verdict: 'QUARANTINE', action: 'TIMEOUT', score: 100 }); + + const { actionTaken } = await enforceScanResult(message, result, storage); + + expect(actionTaken).toBe('TIMEOUT'); + expect(message.delete).toHaveBeenCalledOnce(); + expect(message.member?.timeout).toHaveBeenCalledOnce(); + }); + + it('deletes but does not time out when the member is not moderatable', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage({ member: { moderatable: false, timeout: vi.fn() } }); + const result = makeResult({ verdict: 'QUARANTINE', action: 'TIMEOUT', score: 100 }); + + const { actionTaken } = await enforceScanResult(message, result, storage); + + expect(actionTaken).toBe('DELETE'); + expect(message.delete).toHaveBeenCalledOnce(); + expect(message.member?.timeout).not.toHaveBeenCalled(); + }); + + it('does not time out when message.member is null (e.g. the author left the server)', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage({ member: null }); + const result = makeResult({ verdict: 'QUARANTINE', action: 'TIMEOUT', score: 100 }); + + const { actionTaken } = await enforceScanResult(message, result, storage); + + expect(actionTaken).toBe('DELETE'); + }); + + it('takes no Discord action for a LOG-mode result but still records the audit entry', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + const result = makeResult({ action: 'LOG' }); + + const { actionTaken, auditEntry } = await enforceScanResult(message, result, storage); + + expect(actionTaken).toBe('LOG'); + expect(message.delete).not.toHaveBeenCalled(); + expect(auditEntry.guildId).toBe('guild-1'); + expect(auditEntry.action).toBe('LOG'); + }); + + it('takes no Discord action for a WARN-mode result and records it distinctly from LOG', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + const result = makeResult({ action: 'WARN' }); + + const { actionTaken } = await enforceScanResult(message, result, storage); + + expect(actionTaken).toBe('WARN'); + expect(message.delete).not.toHaveBeenCalled(); + }); + + it('records a ModerationActionRecord alongside the audit log entry', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + + await enforceScanResult(message, makeResult(), storage); + + const actions = await storage.listModerationActions('guild-1'); + expect(actions).toHaveLength(1); + expect(actions[0]?.actionType).toBe('DELETE'); + expect(actions[0]?.userId).toBe('user-1'); + }); + + it('uses a custom timeout duration when provided', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + const result = makeResult({ verdict: 'QUARANTINE', action: 'TIMEOUT' }); + + await enforceScanResult(message, result, storage, { timeoutDurationMs: 60_000 }); + + expect(message.member?.timeout).toHaveBeenCalledWith(60_000, expect.any(String)); + }); + + it('never stores message content in the audit entry', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const message = makeMessage(); + + const { auditEntry } = await enforceScanResult(message, makeResult(), storage); + + expect(auditEntry).not.toHaveProperty('content'); + expect(Object.keys(auditEntry)).not.toContain('message'); + }); +}); diff --git a/packages/discord-bot/test/interaction-create.test.ts b/packages/discord-bot/test/interaction-create.test.ts new file mode 100644 index 0000000..361e9c1 --- /dev/null +++ b/packages/discord-bot/test/interaction-create.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Interaction } from 'discord.js'; +import { MemoryStorageAdapter } from '@antilink-guard/storage'; +import { TokenBucketRateLimiter } from '../src/rate-limit.js'; +import type { BotContext } from '../src/context.js'; +import { handleInteractionCreate } from '../src/events/interaction-create.js'; + +async function makeContext(): Promise { + const storage = new MemoryStorageAdapter(); + await storage.init(); + return { storage, moderationRateLimiter: new TokenBucketRateLimiter(10, 10) }; +} + +describe('handleInteractionCreate', () => { + it('ignores interactions that are not chat input commands', async () => { + const ctx = await makeContext(); + const interaction = { isChatInputCommand: () => false } as unknown as Interaction; + await expect(handleInteractionCreate(interaction, ctx)).resolves.toBeUndefined(); + }); + + it('ignores an unrecognized command name', async () => { + const ctx = await makeContext(); + const interaction = { + isChatInputCommand: () => true, + commandName: 'not-a-real-command', + } as unknown as Interaction; + await expect(handleInteractionCreate(interaction, ctx)).resolves.toBeUndefined(); + }); + + it('routes a recognized command to its handler', async () => { + const ctx = await makeContext(); + const reply = vi.fn().mockResolvedValue(undefined); + const interaction = { + isChatInputCommand: () => true, + commandName: 'antilink', + guildId: 'guild-1', + options: { getSubcommand: () => 'status' }, + reply, + } as unknown as Interaction; + + await handleInteractionCreate(interaction, ctx); + expect(reply).toHaveBeenCalledOnce(); + }); + + it('replies with a generic error and does not throw when a handler fails', async () => { + const ctx = await makeContext(); + const reply = vi.fn().mockResolvedValue(undefined); + const interaction = { + isChatInputCommand: () => true, + commandName: 'antilink', + guildId: 'guild-1', + deferred: false, + replied: false, + options: { + getSubcommand: () => { + throw new Error('boom'); + }, + }, + reply, + } as unknown as Interaction; + + await expect(handleInteractionCreate(interaction, ctx)).resolves.toBeUndefined(); + expect(reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('went wrong') }), + ); + }); + + it('uses followUp instead of reply when the interaction was already replied to', async () => { + const ctx = await makeContext(); + const followUp = vi.fn().mockResolvedValue(undefined); + const interaction = { + isChatInputCommand: () => true, + commandName: 'antilink', + guildId: 'guild-1', + deferred: false, + replied: true, + options: { + getSubcommand: () => { + throw new Error('boom'); + }, + }, + followUp, + } as unknown as Interaction; + + await handleInteractionCreate(interaction, ctx); + expect(followUp).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/discord-bot/test/message-create.test.ts b/packages/discord-bot/test/message-create.test.ts new file mode 100644 index 0000000..0b9766d --- /dev/null +++ b/packages/discord-bot/test/message-create.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Message } from 'discord.js'; +import { MemoryStorageAdapter, createDefaultGuildConfig } from '@antilink-guard/storage'; +import { TokenBucketRateLimiter } from '../src/rate-limit.js'; +import type { BotContext } from '../src/context.js'; +import { handleMessageCreate } from '../src/events/message-create.js'; + +interface FakeMessageOptions { + content: string; + isBot?: boolean; + inGuild?: boolean; + authorRoleIds?: string[]; + deletable?: boolean; + moderatable?: boolean; +} + +function makeMessage(opts: FakeMessageOptions): { + message: Message; + deleteFn: ReturnType; + timeoutFn: ReturnType; + sendFn: ReturnType; +} { + const deleteFn = vi.fn().mockResolvedValue(undefined); + const timeoutFn = vi.fn().mockResolvedValue(undefined); + const sendFn = vi.fn().mockResolvedValue(undefined); + + const fake = { + content: opts.content, + author: { id: 'user-1', bot: opts.isBot ?? false }, + guildId: 'guild-1', + channelId: 'channel-1', + deletable: opts.deletable ?? true, + delete: deleteFn, + member: { + moderatable: opts.moderatable ?? true, + timeout: timeoutFn, + roles: { + cache: { + map: (fn: (r: { id: string }) => string) => + (opts.authorRoleIds ?? []).map((id) => fn({ id })), + }, + }, + }, + mentions: { users: { size: 0 }, roles: { size: 0 } }, + inGuild: () => opts.inGuild ?? true, + client: { + channels: { fetch: vi.fn().mockResolvedValue({ isTextBased: () => true, send: sendFn }) }, + }, + }; + + return { message: fake as unknown as Message, deleteFn, timeoutFn, sendFn }; +} + +async function makeContext(): Promise { + const storage = new MemoryStorageAdapter(); + await storage.init(); + return { storage, moderationRateLimiter: new TokenBucketRateLimiter(10, 10) }; +} + +describe('handleMessageCreate', () => { + it('ignores messages from bots', async () => { + const ctx = await makeContext(); + const { message, deleteFn } = makeMessage({ content: 'https://bad.com', isBot: true }); + await ctx.storage.upsertGuildConfig({ + ...createDefaultGuildConfig('guild-1'), + mode: 'delete', + }); + await handleMessageCreate(message, ctx); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('ignores messages outside of a guild (DMs)', async () => { + const ctx = await makeContext(); + const { message, deleteFn } = makeMessage({ content: 'https://bad.com', inGuild: false }); + await handleMessageCreate(message, ctx); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('does nothing when the guild has moderation disabled', async () => { + const ctx = await makeContext(); + await ctx.storage.upsertGuildConfig({ ...createDefaultGuildConfig('guild-1'), enabled: false }); + await ctx.storage.addBlocklistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'bad.com', + addedBy: 'u', + createdAt: new Date(), + }); + const { message, deleteFn } = makeMessage({ content: 'https://bad.com' }); + await handleMessageCreate(message, ctx); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('deletes a message matching the guild blocklist and records an audit entry', async () => { + const ctx = await makeContext(); + await ctx.storage.upsertGuildConfig({ ...createDefaultGuildConfig('guild-1'), mode: 'delete' }); + await ctx.storage.addBlocklistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'bad.com', + addedBy: 'u', + createdAt: new Date(), + }); + const { message, deleteFn } = makeMessage({ content: 'check https://bad.com now' }); + + await handleMessageCreate(message, ctx); + + expect(deleteFn).toHaveBeenCalledOnce(); + expect(await ctx.storage.listAuditLogEntries('guild-1')).toHaveLength(1); + }); + + it('does not enforce for a bypassed role', async () => { + const ctx = await makeContext(); + await ctx.storage.upsertGuildConfig({ + ...createDefaultGuildConfig('guild-1'), + mode: 'delete', + bypassRoleIds: ['mod-role'], + }); + await ctx.storage.addBlocklistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'bad.com', + addedBy: 'u', + createdAt: new Date(), + }); + const { message, deleteFn } = makeMessage({ + content: 'https://bad.com', + authorRoleIds: ['mod-role'], + }); + + await handleMessageCreate(message, ctx); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('sends a mod-log message when a log channel is configured', async () => { + const ctx = await makeContext(); + await ctx.storage.upsertGuildConfig({ + ...createDefaultGuildConfig('guild-1'), + mode: 'delete', + logChannelId: 'log-channel-1', + }); + await ctx.storage.addBlocklistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'bad.com', + addedBy: 'u', + createdAt: new Date(), + }); + const { message, sendFn } = makeMessage({ content: 'https://bad.com' }); + + await handleMessageCreate(message, ctx); + expect(sendFn).toHaveBeenCalledOnce(); + }); + + it('skips enforcement once the moderation rate limit is exhausted', async () => { + const storage = new MemoryStorageAdapter(); + await storage.init(); + const ctx: BotContext = { storage, moderationRateLimiter: new TokenBucketRateLimiter(1, 1) }; + + await storage.upsertGuildConfig({ ...createDefaultGuildConfig('guild-1'), mode: 'delete' }); + await storage.addBlocklistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'bad.com', + addedBy: 'u', + createdAt: new Date(), + }); + + const first = makeMessage({ content: 'https://bad.com' }); + const second = makeMessage({ content: 'https://bad.com' }); + + await handleMessageCreate(first.message, ctx); + await handleMessageCreate(second.message, ctx); + + expect(first.deleteFn).toHaveBeenCalledOnce(); + expect(second.deleteFn).not.toHaveBeenCalled(); + }); + + it('does not throw when message content has no links at all', async () => { + const ctx = await makeContext(); + const { message } = makeMessage({ content: 'just chatting, no links here' }); + await expect(handleMessageCreate(message, ctx)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/discord-bot/test/mod-log.test.ts b/packages/discord-bot/test/mod-log.test.ts new file mode 100644 index 0000000..7223a9c --- /dev/null +++ b/packages/discord-bot/test/mod-log.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AuditLogEntry } from '@antilink-guard/storage'; +import { + buildModLogEmbed, + sendModLog, + type ModLogChannelSource, +} from '../src/moderation/mod-log.js'; + +function makeEntry(overrides: Partial = {}): AuditLogEntry { + return { + id: 'entry-1', + guildId: 'guild-1', + channelId: 'channel-1', + userId: 'user-1', + normalizedUrl: 'https://bad-site.com', + hostname: 'bad-site.com', + verdict: 'BLOCK', + reasons: ['BLOCKLIST_MATCH'], + score: 50, + action: 'DELETE', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, + }; +} + +describe('buildModLogEmbed', () => { + it('includes the user, channel, action, verdict, score, and reasons', () => { + const embed = buildModLogEmbed(makeEntry()); + const json = embed.toJSON(); + const fieldNames = json.fields?.map((f) => f.name) ?? []; + + expect(fieldNames).toEqual( + expect.arrayContaining(['User', 'Channel', 'Action', 'Verdict', 'Score', 'Reasons', 'URL']), + ); + }); + + it('omits the URL field when there is no normalized URL', () => { + const embed = buildModLogEmbed(makeEntry({ normalizedUrl: undefined })); + const fieldNames = embed.toJSON().fields?.map((f) => f.name) ?? []; + expect(fieldNames).not.toContain('URL'); + }); + + it('sets the embed timestamp to the entry creation time', () => { + const entry = makeEntry(); + const embed = buildModLogEmbed(entry); + expect(embed.toJSON().timestamp).toBe(entry.createdAt.toISOString()); + }); +}); + +describe('sendModLog', () => { + it('sends an embed to a fetched text-based channel', async () => { + const send = vi.fn().mockResolvedValue(undefined); + const client: ModLogChannelSource = { + channels: { + fetch: vi.fn().mockResolvedValue({ isTextBased: () => true, send }), + }, + }; + + await sendModLog(client, 'log-channel-1', makeEntry()); + + expect(send).toHaveBeenCalledOnce(); + }); + + it('does nothing when the channel cannot be found', async () => { + const client: ModLogChannelSource = { + channels: { fetch: vi.fn().mockResolvedValue(null) }, + }; + + await expect(sendModLog(client, 'missing-channel', makeEntry())).resolves.toBeUndefined(); + }); + + it('does nothing when the channel is not text-based', async () => { + const send = vi.fn(); + const client: ModLogChannelSource = { + channels: { + fetch: vi.fn().mockResolvedValue({ isTextBased: () => false, send }), + }, + }; + + await sendModLog(client, 'voice-channel-1', makeEntry()); + expect(send).not.toHaveBeenCalled(); + }); + + it('swallows an error thrown while fetching the channel', async () => { + const client: ModLogChannelSource = { + channels: { fetch: vi.fn().mockRejectedValue(new Error('network error')) }, + }; + + await expect(sendModLog(client, 'log-channel-1', makeEntry())).resolves.toBeUndefined(); + }); +}); diff --git a/packages/discord-bot/test/policy-from-config.test.ts b/packages/discord-bot/test/policy-from-config.test.ts new file mode 100644 index 0000000..df689b9 --- /dev/null +++ b/packages/discord-bot/test/policy-from-config.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; +import { buildPolicyConfig } from '../src/policy-from-config.js'; + +describe('buildPolicyConfig', () => { + it('maps guild config fields directly onto the policy', () => { + const guildConfig = { + ...createDefaultGuildConfig('guild-1'), + enabled: false, + mode: 'timeout' as const, + blockAllInvites: true, + requireAllowlist: true, + flagUnknownDomains: true, + massMentionThreshold: 7, + bypassRoleIds: ['role-1'], + bypassUserIds: ['user-1'], + }; + + const policy = buildPolicyConfig({ + guildConfig, + allowlist: [], + blocklist: [], + inviteRules: [], + }); + + expect(policy.enabled).toBe(false); + expect(policy.mode).toBe('timeout'); + expect(policy.blockAllInvites).toBe(true); + expect(policy.requireAllowlist).toBe(true); + expect(policy.flagUnknownDomains).toBe(true); + expect(policy.massMentionThreshold).toBe(7); + expect(policy.bypassRoleIds).toEqual(['role-1']); + expect(policy.bypassUserIds).toEqual(['user-1']); + }); + + it('maps allowlist/blocklist entries to their domain strings', () => { + const policy = buildPolicyConfig({ + guildConfig: createDefaultGuildConfig('guild-1'), + allowlist: [ + { id: '1', guildId: 'guild-1', domain: 'good.com', addedBy: 'u', createdAt: new Date() }, + ], + blocklist: [ + { id: '2', guildId: 'guild-1', domain: 'bad.com', addedBy: 'u', createdAt: new Date() }, + ], + inviteRules: [], + }); + + expect(policy.domainAllowlist).toEqual(['good.com']); + expect(policy.domainBlocklist).toEqual(['bad.com']); + }); + + it('maps invite rules to invite codes', () => { + const policy = buildPolicyConfig({ + guildConfig: createDefaultGuildConfig('guild-1'), + allowlist: [], + blocklist: [], + inviteRules: [ + { id: '1', guildId: 'guild-1', inviteCode: 'abc123', addedBy: 'u', createdAt: new Date() }, + ], + }); + + expect(policy.inviteAllowlist).toEqual(['abc123']); + }); + + it('never populates a known-phishing domain list on its own', () => { + const policy = buildPolicyConfig({ + guildConfig: createDefaultGuildConfig('guild-1'), + allowlist: [], + blocklist: [], + inviteRules: [], + }); + + expect(policy.knownPhishingDomains).toBeUndefined(); + }); +}); diff --git a/packages/discord-bot/test/rate-limit.test.ts b/packages/discord-bot/test/rate-limit.test.ts new file mode 100644 index 0000000..d9c676a --- /dev/null +++ b/packages/discord-bot/test/rate-limit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { TokenBucketRateLimiter } from '../src/rate-limit.js'; + +function fakeClock(startMs = 0): { now: () => number; advance: (ms: number) => void } { + let current = startMs; + return { now: () => current, advance: (ms: number) => (current += ms) }; +} + +describe('TokenBucketRateLimiter', () => { + it('allows up to the bucket capacity in an instant burst', () => { + const clock = fakeClock(); + const limiter = new TokenBucketRateLimiter(3, 1, clock.now); + + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(false); + }); + + it('refills tokens over time at the configured rate', () => { + const clock = fakeClock(); + const limiter = new TokenBucketRateLimiter(1, 1, clock.now); + + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(false); + + clock.advance(500); + expect(limiter.tryConsume('guild-1')).toBe(false); + + clock.advance(600); + expect(limiter.tryConsume('guild-1')).toBe(true); + }); + + it('never refills beyond capacity', () => { + const clock = fakeClock(); + const limiter = new TokenBucketRateLimiter(2, 100, clock.now); + + clock.advance(60_000); + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(false); + }); + + it('tracks separate buckets per key', () => { + const clock = fakeClock(); + const limiter = new TokenBucketRateLimiter(1, 1, clock.now); + + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-2')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(false); + }); + + it('reset() clears a specific key', () => { + const clock = fakeClock(); + const limiter = new TokenBucketRateLimiter(1, 1, clock.now); + + expect(limiter.tryConsume('guild-1')).toBe(true); + expect(limiter.tryConsume('guild-1')).toBe(false); + limiter.reset('guild-1'); + expect(limiter.tryConsume('guild-1')).toBe(true); + }); + + it('rejects a non-positive capacity or refill rate', () => { + expect(() => new TokenBucketRateLimiter(0, 1)).toThrow(); + expect(() => new TokenBucketRateLimiter(1, 0)).toThrow(); + }); +}); diff --git a/packages/discord-bot/tsconfig.json b/packages/discord-bot/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/discord-bot/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/storage/src/config-bundle.ts b/packages/storage/src/config-bundle.ts index 8f955ed..3e52eb1 100644 --- a/packages/storage/src/config-bundle.ts +++ b/packages/storage/src/config-bundle.ts @@ -10,7 +10,7 @@ import { createDefaultGuildConfig } from './types.js'; export const CONFIG_BUNDLE_VERSION = 1 as const; -const enforcementModeSchema = z.enum(['log', 'delete', 'timeout']); +const enforcementModeSchema = z.enum(['log', 'warn', 'delete', 'timeout']); const guildConfigSchema = z.object({ guildId: z.string().min(1), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba74e96..96247d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,34 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.20.0) + packages/discord-bot: + dependencies: + '@antilink-guard/core': + specifier: workspace:* + version: link:../core + '@antilink-guard/storage': + specifier: workspace:* + version: link:../storage + discord.js: + specifier: ^14.26.4 + version: 14.26.4 + pino: + specifier: ^10.3.1 + version: 10.3.1 + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.0) + packages/storage: dependencies: '@antilink-guard/core': @@ -127,6 +155,34 @@ importers: packages: + '@discordjs/builders@1.14.1': + resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.6.2': + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/rest@2.6.1': + resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==} + engines: {node: '>=18'} + + '@discordjs/util@1.2.0': + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} + engines: {node: '>=18'} + + '@discordjs/ws@1.2.3': + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} + engines: {node: '>=16.11.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -492,6 +548,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -630,6 +689,22 @@ packages: cpu: [x64] os: [win32] + '@sapphire/async-queue@1.5.5': + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/snowflake@3.5.5': + resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -645,6 +720,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.63.0': resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -733,6 +811,10 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -760,6 +842,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -887,6 +973,13 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + discord-api-types@0.38.49: + resolution: {integrity: sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==} + + discord.js@14.26.4: + resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==} + engines: {node: '>=18'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -1124,6 +1217,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -1134,6 +1233,9 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-bytes.js@1.13.0: + resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1192,6 +1294,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1270,6 +1376,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -1330,6 +1446,9 @@ packages: engines: {node: '>=14'} hasBin: true + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1337,6 +1456,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -1349,6 +1471,13 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1365,6 +1494,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1390,6 +1523,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1446,6 +1582,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1481,6 +1621,12 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -1525,6 +1671,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1609,6 +1759,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -1622,6 +1784,55 @@ packages: snapshots: + '@discordjs/builders@1.14.1': + dependencies: + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.49 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.6.2': + dependencies: + discord-api-types: 0.38.49 + + '@discordjs/rest@2.6.1': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.5 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.49 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.24.1 + + '@discordjs/util@1.2.0': + dependencies: + discord-api-types: 0.38.49 + + '@discordjs/ws@1.2.3': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.49 + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1845,6 +2056,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@pinojs/redact@0.4.0': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -1920,6 +2133,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sapphire/async-queue@1.5.5': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.18.1 + + '@sapphire/snowflake@3.5.3': {} + + '@sapphire/snowflake@3.5.5': {} + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 22.20.0 @@ -1938,6 +2162,10 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2069,6 +2297,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vladfrangu/async_event_emitter@2.4.7': {} + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -2092,6 +2322,8 @@ snapshots: assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + aws-ssl-profiles@1.1.2: {} balanced-match@1.0.2: {} @@ -2199,6 +2431,27 @@ snapshots: detect-libc@2.1.2: {} + discord-api-types@0.38.49: {} + + discord.js@14.26.4: + dependencies: + '@discordjs/builders': 1.14.1 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.6.2 + '@discordjs/rest': 2.6.1 + '@discordjs/util': 1.2.0 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.3 + discord-api-types: 0.38.49 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.24.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2461,12 +2714,18 @@ snapshots: lodash.merge@4.6.2: {} + lodash.snakecase@4.1.1: {} + + lodash@4.18.1: {} + long@5.3.2: {} loupe@3.2.1: {} lru.min@1.1.4: {} + magic-bytes.js@1.13.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2528,6 +2787,8 @@ snapshots: object-assign@4.1.1: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -2602,6 +2863,26 @@ snapshots: picomatch@4.0.5: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} pkg-types@1.3.1: @@ -2651,6 +2932,8 @@ snapshots: prettier@3.9.4: {} + process-warning@5.0.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -2658,6 +2941,8 @@ snapshots: punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -2673,6 +2958,10 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -2710,6 +2999,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -2730,6 +3021,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -2787,6 +3082,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2810,6 +3109,10 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-mixer@6.0.4: {} + + tslib@2.8.1: {} + tsup@8.5.1(postcss@8.5.16)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) @@ -2863,6 +3166,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.24.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2944,6 +3249,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + xtend@4.0.2: {} yocto-queue@0.1.0: {} diff --git a/vitest.config.ts b/vitest.config.ts index 6e2ceec..058e3bd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], + env: { + LOG_LEVEL: 'silent', + }, coverage: { reporter: ['text', 'lcov'], }, From b25bfa9fdce3245e43d345dcfb9a9cd120c1f3e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 22:10:43 +0000 Subject: [PATCH 7/9] feat: add apps/example-bot - a working self-hosted example + Docker Compose A minimal, real bot assembled entirely from the published @antilink-guard/* packages: SqliteStorageAdapter + createBot from discord-bot, nothing else. - src/index.ts - loads .env, creates the data directory, opens SQLite, logs in - src/register-commands.ts - one-shot script to register the 7 slash command groups, guild-scoped (instant) or global - .env.example - DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID, DATABASE_SQLITE_PATH, LOG_LEVEL - README.md - Discord Developer Portal setup (bot permissions, Message Content Intent), env var table, command registration, Docker Compose usage - Dockerfile - multi-stage pnpm workspace build, non-root user, a named volume for the SQLite file - root docker-compose.yml - builds and runs it with `docker compose up` Verified end-to-end in this session (not just written): with no token it exits with a clear error; with a token it creates the data directory, initializes a real SQLite database (confirmed via the WAL files it wrote), and proceeds all the way to a genuine Discord gateway request - which only fails here because this sandbox's network policy blocks discord.com, not because of anything in the code. `docker compose config` fully resolves the compose file (build context, env vars, volume) with no errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- apps/example-bot/.env.example | 20 ++++++ apps/example-bot/Dockerfile | 33 +++++++++ apps/example-bot/README.md | 83 +++++++++++++++++++++++ apps/example-bot/package.json | 25 +++++++ apps/example-bot/src/index.ts | 20 ++++++ apps/example-bot/src/register-commands.ts | 18 +++++ apps/example-bot/tsconfig.json | 8 +++ docker-compose.yml | 13 ++++ pnpm-lock.yaml | 31 +++++++++ 9 files changed, 251 insertions(+) create mode 100644 apps/example-bot/.env.example create mode 100644 apps/example-bot/Dockerfile create mode 100644 apps/example-bot/README.md create mode 100644 apps/example-bot/package.json create mode 100644 apps/example-bot/src/index.ts create mode 100644 apps/example-bot/src/register-commands.ts create mode 100644 apps/example-bot/tsconfig.json create mode 100644 docker-compose.yml diff --git a/apps/example-bot/.env.example b/apps/example-bot/.env.example new file mode 100644 index 0000000..8f7f752 --- /dev/null +++ b/apps/example-bot/.env.example @@ -0,0 +1,20 @@ +# AntiLink Guard OSS - example bot environment configuration +# Copy this file to .env and fill in your values. Never commit your real .env. + +# Required: your Discord bot token (Developer Portal -> Bot -> Token) +DISCORD_TOKEN= + +# Required for `pnpm run register-commands`: your application's Client ID +# (Developer Portal -> General Information -> Application ID) +DISCORD_CLIENT_ID= + +# Optional: a single guild (server) ID to register commands to for local +# development. Guild commands update instantly; global commands (leave this +# blank) can take up to an hour to propagate. +DISCORD_GUILD_ID= + +# SQLite is the default, zero-setup storage backend for self-hosting. +DATABASE_SQLITE_PATH=./data/antilink.sqlite + +# Log verbosity: trace, debug, info, warn, error, silent +LOG_LEVEL=info diff --git a/apps/example-bot/Dockerfile b/apps/example-bot/Dockerfile new file mode 100644 index 0000000..eff89a9 --- /dev/null +++ b/apps/example-bot/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 +FROM node:20-slim AS base +RUN corepack enable +WORKDIR /app + +FROM base AS deps +# better-sqlite3 usually pulls a prebuilt binary, but these cover the fallback +# native-compile path so the build never breaks on an unsupported platform. +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY packages/core/package.json packages/core/package.json +COPY packages/storage/package.json packages/storage/package.json +COPY packages/discord-bot/package.json packages/discord-bot/package.json +COPY packages/cli/package.json packages/cli/package.json +COPY apps/example-bot/package.json apps/example-bot/package.json +RUN pnpm install --frozen-lockfile + +FROM deps AS build +COPY . . +RUN pnpm --filter "@antilink-guard/core" --filter "@antilink-guard/storage" \ + --filter "@antilink-guard/discord-bot" --filter "@antilink-guard/example-bot" run build + +FROM base AS runtime +ENV NODE_ENV=production +COPY --from=build /app /app +WORKDIR /app/apps/example-bot +RUN useradd --create-home --shell /usr/sbin/nologin appuser \ + && mkdir -p data \ + && chown -R appuser:appuser /app/apps/example-bot/data +USER appuser +VOLUME ["/app/apps/example-bot/data"] +CMD ["node", "dist/index.js"] diff --git a/apps/example-bot/README.md b/apps/example-bot/README.md new file mode 100644 index 0000000..9a1f431 --- /dev/null +++ b/apps/example-bot/README.md @@ -0,0 +1,83 @@ +# AntiLink Guard OSS - example bot + +A minimal, working self-hosted bot built entirely from the published +`@antilink-guard/*` packages. This is the fastest way to run AntiLink Guard +OSS in your own server - clone the repo, fill in a `.env`, and go. + +## 1. Create a Discord application + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. +2. Under **Bot**, click **Reset Token** to get your bot token, and enable the + **Message Content Intent** (required - the bot reads message text to scan for links). +3. Under **General Information**, copy the **Application ID** (this is your `DISCORD_CLIENT_ID`). +4. Under **OAuth2 -> URL Generator**, select the `bot` and `applications.commands` scopes, and these + bot permissions: + - View Channels + - Send Messages + - Manage Messages (to delete flagged messages) + - Moderate Members (to time out members, only used in `timeout` mode) + - Embed Links (for mod-log embeds) +5. Open the generated URL and invite the bot to your test server. + +## 2. Configure + +From the repository root: + +```bash +pnpm install +cp apps/example-bot/.env.example apps/example-bot/.env +``` + +Edit `apps/example-bot/.env`: + +| Variable | Required | Description | +| ---------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_TOKEN` | Yes | Your bot's token. | +| `DISCORD_CLIENT_ID` | For command registration | Your application's ID. | +| `DISCORD_GUILD_ID` | No | Register commands to one server for instant updates during development. Leave blank to register globally (takes up to ~1 hour to propagate). | +| `DATABASE_SQLITE_PATH` | No | Defaults to `./data/antilink.sqlite`. | +| `LOG_LEVEL` | No | `trace`\|`debug`\|`info`\|`warn`\|`error`\|`silent`. Defaults to `info`. | + +## 3. Register slash commands + +Slash commands must be registered once (and again whenever they change): + +```bash +pnpm --filter @antilink-guard/example-bot build +pnpm --filter @antilink-guard/example-bot run register-commands +``` + +## 4. Run it + +```bash +pnpm --filter @antilink-guard/example-bot run start +``` + +You should see `AntiLink Guard OSS is ready` logged once the bot connects. Run +`/antilink status` in your server to confirm, then `/antilink enable` and +`/antilink mode block` to turn on protection. + +## Docker Compose + +From the repository root, with `apps/example-bot/.env` already filled in: + +```bash +docker compose up --build +``` + +This builds the whole workspace inside the container and runs the bot with a +persistent named volume for its SQLite database (`antilink-data`), so your +configuration survives container restarts and rebuilds. + +## Storage + +This example uses **SQLite** (via `@antilink-guard/storage`'s +`SqliteStorageAdapter`) - a single file, no separate database server to run. +For MySQL or PostgreSQL, swap the adapter construction in `src/index.ts`; see +[`docs/self-hosting.md`](../../docs/self-hosting.md). + +## What this example does and does not include + +- Includes: the full moderation pipeline, all slash commands, mod-log embeds. +- Does not include: a web dashboard (see `apps/dashboard-lite`), multi-shard + scaling, or any of the hosted AntiLink platform's paid features. diff --git a/apps/example-bot/package.json b/apps/example-bot/package.json new file mode 100644 index 0000000..ca97820 --- /dev/null +++ b/apps/example-bot/package.json @@ -0,0 +1,25 @@ +{ + "name": "@antilink-guard/example-bot", + "version": "0.1.0", + "private": true, + "description": "A minimal, working self-hosted example of AntiLink Guard OSS.", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsup src/index.ts src/register-commands.ts --format esm --clean", + "start": "node dist/index.js", + "register-commands": "node dist/register-commands.js", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@antilink-guard/core": "workspace:*", + "@antilink-guard/discord-bot": "workspace:*", + "@antilink-guard/storage": "workspace:*", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.6.3" + } +} diff --git a/apps/example-bot/src/index.ts b/apps/example-bot/src/index.ts new file mode 100644 index 0000000..fbc19e5 --- /dev/null +++ b/apps/example-bot/src/index.ts @@ -0,0 +1,20 @@ +import 'dotenv/config'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { SqliteStorageAdapter } from '@antilink-guard/storage'; +import { createBot } from '@antilink-guard/discord-bot'; + +const token = process.env.DISCORD_TOKEN; +if (!token) { + console.error('DISCORD_TOKEN is not set. Copy .env.example to .env and fill it in.'); + process.exit(1); +} + +const dbPath = resolve(process.env.DATABASE_SQLITE_PATH ?? './data/antilink.sqlite'); +mkdirSync(dirname(dbPath), { recursive: true }); + +const storage = new SqliteStorageAdapter({ filename: dbPath }); +await storage.init(); + +const client = createBot({ storage }); +await client.login(token); diff --git a/apps/example-bot/src/register-commands.ts b/apps/example-bot/src/register-commands.ts new file mode 100644 index 0000000..993daf8 --- /dev/null +++ b/apps/example-bot/src/register-commands.ts @@ -0,0 +1,18 @@ +import 'dotenv/config'; +import { registerCommands } from '@antilink-guard/discord-bot'; + +const token = process.env.DISCORD_TOKEN; +const clientId = process.env.DISCORD_CLIENT_ID; +const guildId = process.env.DISCORD_GUILD_ID; + +if (!token || !clientId) { + console.error('DISCORD_TOKEN and DISCORD_CLIENT_ID must be set to register commands.'); + process.exit(1); +} + +await registerCommands({ token, clientId, guildId: guildId || undefined }); +console.log( + guildId + ? `Slash commands registered to guild ${guildId} (updates instantly).` + : 'Slash commands registered globally (may take up to an hour to propagate).', +); diff --git a/apps/example-bot/tsconfig.json b/apps/example-bot/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/apps/example-bot/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4454666 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + antilink-guard: + build: + context: . + dockerfile: apps/example-bot/Dockerfile + env_file: + - apps/example-bot/.env + volumes: + - antilink-data:/app/apps/example-bot/data + restart: unless-stopped + +volumes: + antilink-data: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96247d0..2e7f862 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,31 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.20.0) + apps/example-bot: + dependencies: + '@antilink-guard/core': + specifier: workspace:* + version: link:../../packages/core + '@antilink-guard/discord-bot': + specifier: workspace:* + version: link:../../packages/discord-bot + '@antilink-guard/storage': + specifier: workspace:* + version: link:../../packages/storage + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + packages/cli: dependencies: '@antilink-guard/core': @@ -980,6 +1005,10 @@ packages: resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==} engines: {node: '>=18'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2452,6 +2481,8 @@ snapshots: - bufferutil - utf-8-validate + dotenv@16.6.1: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 From dc093f2389b1c364bb22c1e920d52302489a94cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 22:14:49 +0000 Subject: [PATCH 8/9] feat: add apps/dashboard-lite - a minimal local read-only dashboard A dependency-free (plain node:http, no framework) local dashboard that reads directly from the same storage a self-hosted bot uses: - Bot status per guild (enabled, mode, log channel, list counts, last audit entry time) - Full guild configuration - Allowlist and blocklist entries (with blocklist reasons) - The 50 most recent audit log entries No paid features, and explicitly no Discord OAuth or login of any kind - it's a local, unauthenticated, read-only tool by design, with that limitation called out prominently in both the UI banner and the README rather than left implicit. Real Discord OAuth is noted as a documented, opt-in future enhancement, not something bolted on half-finished here. Verified end-to-end: 8 integration tests start the actual HTTP server on an ephemeral port and hit it with real fetch() calls (not mocks), plus a manual smoke test running the built server and curling both the HTML page and a JSON API route. 194 tests pass across the whole workspace. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- apps/dashboard-lite/.env.example | 9 ++ apps/dashboard-lite/README.md | 50 +++++++ apps/dashboard-lite/package.json | 24 +++ apps/dashboard-lite/public/index.html | 190 ++++++++++++++++++++++++ apps/dashboard-lite/src/index.ts | 17 +++ apps/dashboard-lite/src/server.ts | 106 +++++++++++++ apps/dashboard-lite/test/server.test.ts | 122 +++++++++++++++ apps/dashboard-lite/tsconfig.json | 8 + pnpm-lock.yaml | 22 +++ 9 files changed, 548 insertions(+) create mode 100644 apps/dashboard-lite/.env.example create mode 100644 apps/dashboard-lite/README.md create mode 100644 apps/dashboard-lite/package.json create mode 100644 apps/dashboard-lite/public/index.html create mode 100644 apps/dashboard-lite/src/index.ts create mode 100644 apps/dashboard-lite/src/server.ts create mode 100644 apps/dashboard-lite/test/server.test.ts create mode 100644 apps/dashboard-lite/tsconfig.json diff --git a/apps/dashboard-lite/.env.example b/apps/dashboard-lite/.env.example new file mode 100644 index 0000000..45efac5 --- /dev/null +++ b/apps/dashboard-lite/.env.example @@ -0,0 +1,9 @@ +# AntiLink Guard OSS dashboard-lite - environment configuration +# Copy this file to .env and adjust as needed. + +# Path to the SAME SQLite database file your example-bot instance uses. +DATABASE_SQLITE_PATH=../example-bot/data/antilink.sqlite + +# Port to serve the dashboard on. Bind to localhost only in production use - +# this tool has no authentication (see README.md). +PORT=4000 diff --git a/apps/dashboard-lite/README.md b/apps/dashboard-lite/README.md new file mode 100644 index 0000000..4d1c0ce --- /dev/null +++ b/apps/dashboard-lite/README.md @@ -0,0 +1,50 @@ +# AntiLink Guard OSS - dashboard-lite + +A minimal, **local, read-only** dashboard for a self-hosted AntiLink Guard OSS +bot. It reads directly from the same SQLite database your `example-bot` +instance uses and shows: + +- Bot status for a guild (enabled, mode, log channel, counts) +- The full guild configuration +- Allowlist and blocklist entries +- The 50 most recent audit log entries + +It does **not** modify anything - all configuration changes go through the +bot's slash commands (`/antilink`, `/allowlist`, `/blocklist`, `/invites`, +`/logs`). + +## Important: no authentication + +This tool has **no login, no sessions, and no Discord OAuth**. It is meant to +run on `localhost` next to your bot for your own use. **Do not expose it to +the public internet** - anyone who can reach it can read your server's full +moderation configuration and audit history. + +If you want a hosted, authenticated, multi-admin dashboard, that is a +separate concern from this OSS framework (see the project README's +"hosted platform" note). Adding real Discord OAuth here is tracked as a +documented, opt-in future enhancement - not something this tool does by +default. + +## Setup + +```bash +pnpm install +cp apps/dashboard-lite/.env.example apps/dashboard-lite/.env +``` + +Point `DATABASE_SQLITE_PATH` in `.env` at the same SQLite file your +`example-bot` is using (the default assumes both apps run from a sibling +directory layout, as in this repo). + +```bash +pnpm --filter @antilink-guard/dashboard-lite build +pnpm --filter @antilink-guard/dashboard-lite run start +``` + +Open `http://localhost:4000`, enter your server's guild ID, and click **Load**. + +## Finding your guild ID + +In Discord, enable Developer Mode (User Settings -> Advanced), then right-click +your server's icon and choose **Copy Server ID**. diff --git a/apps/dashboard-lite/package.json b/apps/dashboard-lite/package.json new file mode 100644 index 0000000..b011e37 --- /dev/null +++ b/apps/dashboard-lite/package.json @@ -0,0 +1,24 @@ +{ + "name": "@antilink-guard/dashboard-lite", + "version": "0.1.0", + "private": true, + "description": "A minimal, local, read-only dashboard for a self-hosted AntiLink Guard OSS bot.", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsup src/index.ts src/server.ts --format esm --clean", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@antilink-guard/storage": "workspace:*", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/apps/dashboard-lite/public/index.html b/apps/dashboard-lite/public/index.html new file mode 100644 index 0000000..356e88d --- /dev/null +++ b/apps/dashboard-lite/public/index.html @@ -0,0 +1,190 @@ + + + + + + AntiLink Guard OSS - dashboard-lite + + + +

AntiLink Guard OSS - dashboard-lite

+ + +
+ + +
+ +
+

Status

+
Enter a guild ID above.
+
+ +
+

Allowlist

+
-
+
+ +
+

Blocklist

+
-
+
+ +
+

Recent audit log

+
-
+
+ + + + diff --git a/apps/dashboard-lite/src/index.ts b/apps/dashboard-lite/src/index.ts new file mode 100644 index 0000000..4119f1a --- /dev/null +++ b/apps/dashboard-lite/src/index.ts @@ -0,0 +1,17 @@ +import 'dotenv/config'; +import { SqliteStorageAdapter } from '@antilink-guard/storage'; +import { createDashboardServer } from './server.js'; + +const dbPath = process.env.DATABASE_SQLITE_PATH ?? '../example-bot/data/antilink.sqlite'; +const port = Number(process.env.PORT ?? 4000); + +const storage = new SqliteStorageAdapter({ filename: dbPath }); +await storage.init(); + +const server = createDashboardServer(storage); +server.listen(port, () => { + console.log(`AntiLink Guard OSS dashboard-lite listening on http://localhost:${port}`); + console.log( + 'This is a local, unauthenticated read-only tool - do not expose it to the internet.', + ); +}); diff --git a/apps/dashboard-lite/src/server.ts b/apps/dashboard-lite/src/server.ts new file mode 100644 index 0000000..ec19cb9 --- /dev/null +++ b/apps/dashboard-lite/src/server.ts @@ -0,0 +1,106 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import type { StorageAdapter } from '@antilink-guard/storage'; +import { createDefaultGuildConfig } from '@antilink-guard/storage'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const INDEX_HTML_PATH = join(currentDir, '..', 'public', 'index.html'); + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(body)); +} + +async function handleGuildResource( + storage: StorageAdapter, + guildId: string, + resource: string, + searchParams: URLSearchParams, + res: ServerResponse, +): Promise { + if (resource === 'status') { + const [guildConfig, allowlist, blocklist, inviteRules, recentAudit] = await Promise.all([ + storage.getGuildConfig(guildId), + storage.listAllowlistEntries(guildId), + storage.listBlocklistEntries(guildId), + storage.listInviteRules(guildId), + storage.listAuditLogEntries(guildId, { limit: 1 }), + ]); + sendJson(res, 200, { + guildConfig: guildConfig ?? createDefaultGuildConfig(guildId), + counts: { + allowlist: allowlist.length, + blocklist: blocklist.length, + inviteRules: inviteRules.length, + }, + lastAuditAt: recentAudit[0]?.createdAt ?? null, + }); + return true; + } + + if (resource === 'config') { + const config = (await storage.getGuildConfig(guildId)) ?? createDefaultGuildConfig(guildId); + sendJson(res, 200, config); + return true; + } + + if (resource === 'allowlist') { + sendJson(res, 200, await storage.listAllowlistEntries(guildId)); + return true; + } + + if (resource === 'blocklist') { + sendJson(res, 200, await storage.listBlocklistEntries(guildId)); + return true; + } + + if (resource === 'audit-logs') { + const limit = Number(searchParams.get('limit') ?? '50'); + sendJson(res, 200, await storage.listAuditLogEntries(guildId, { limit })); + return true; + } + + return false; +} + +export function createDashboardServer(storage: StorageAdapter): Server { + return createServer((req: IncomingMessage, res: ServerResponse) => { + void (async () => { + try { + const url = new URL(req.url ?? '/', 'http://localhost'); + + if (req.method === 'GET' && url.pathname === '/') { + const html = await readFile(INDEX_HTML_PATH, 'utf8'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + return; + } + + const parts = url.pathname.split('/').filter(Boolean); + if ( + req.method === 'GET' && + parts[0] === 'api' && + parts[1] === 'guild' && + parts[2] && + parts[3] + ) { + const guildId = decodeURIComponent(parts[2]); + const handled = await handleGuildResource( + storage, + guildId, + parts[3], + url.searchParams, + res, + ); + if (handled) return; + } + + sendJson(res, 404, { error: 'Not found' }); + } catch (error) { + sendJson(res, 500, { error: error instanceof Error ? error.message : 'Internal error' }); + } + })(); + }); +} diff --git a/apps/dashboard-lite/test/server.test.ts b/apps/dashboard-lite/test/server.test.ts new file mode 100644 index 0000000..aac657b --- /dev/null +++ b/apps/dashboard-lite/test/server.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { MemoryStorageAdapter, createDefaultGuildConfig } from '@antilink-guard/storage'; +import { createDashboardServer } from '../src/server.js'; + +describe('dashboard-lite server', () => { + let storage: MemoryStorageAdapter; + let server: ReturnType; + let baseUrl: string; + + beforeEach(async () => { + storage = new MemoryStorageAdapter(); + await storage.init(); + server = createDashboardServer(storage); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('serves the dashboard HTML at /', async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + expect(await res.text()).toContain('dashboard-lite'); + }); + + it('returns 404 for an unknown route', async () => { + const res = await fetch(`${baseUrl}/not-a-real-route`); + expect(res.status).toBe(404); + }); + + it('returns default guild config and zeroed counts for a fresh guild', async () => { + const res = await fetch(`${baseUrl}/api/guild/guild-1/status`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.guildConfig.guildId).toBe('guild-1'); + expect(body.counts).toEqual({ allowlist: 0, blocklist: 0, inviteRules: 0 }); + expect(body.lastAuditAt).toBeNull(); + }); + + it('reflects a stored guild config in /status and /config', async () => { + await storage.upsertGuildConfig({ + ...createDefaultGuildConfig('guild-1'), + mode: 'timeout', + logChannelId: 'channel-99', + }); + + const status = await (await fetch(`${baseUrl}/api/guild/guild-1/status`)).json(); + expect(status.guildConfig.mode).toBe('timeout'); + expect(status.guildConfig.logChannelId).toBe('channel-99'); + + const config = await (await fetch(`${baseUrl}/api/guild/guild-1/config`)).json(); + expect(config.mode).toBe('timeout'); + }); + + it('lists allowlist entries for a guild', async () => { + await storage.addAllowlistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'example.com', + addedBy: 'user-1', + createdAt: new Date(), + }); + + const res = await fetch(`${baseUrl}/api/guild/guild-1/allowlist`); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0].domain).toBe('example.com'); + }); + + it('lists blocklist entries with their reason for a guild', async () => { + await storage.addBlocklistEntry({ + id: '1', + guildId: 'guild-1', + domain: 'bad.com', + addedBy: 'user-1', + reason: 'phishing report', + createdAt: new Date(), + }); + + const res = await fetch(`${baseUrl}/api/guild/guild-1/blocklist`); + const body = await res.json(); + expect(body[0].reason).toBe('phishing report'); + }); + + it('lists recent audit log entries, respecting the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await storage.addAuditLogEntry({ + id: `entry-${i}`, + guildId: 'guild-1', + channelId: 'channel-1', + userId: 'user-1', + verdict: 'BLOCK', + reasons: ['BLOCKLIST_MATCH'], + score: 50, + action: 'DELETE', + createdAt: new Date(), + }); + } + + const res = await fetch(`${baseUrl}/api/guild/guild-1/audit-logs?limit=2`); + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it('scopes every resource to the requested guild only', async () => { + await storage.addAllowlistEntry({ + id: '1', + guildId: 'other-guild', + domain: 'not-mine.com', + addedBy: 'user-1', + createdAt: new Date(), + }); + + const res = await fetch(`${baseUrl}/api/guild/guild-1/allowlist`); + expect(await res.json()).toHaveLength(0); + }); +}); diff --git a/apps/dashboard-lite/tsconfig.json b/apps/dashboard-lite/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/apps/dashboard-lite/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e7f862..3ed41ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,28 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.20.0) + apps/dashboard-lite: + dependencies: + '@antilink-guard/storage': + specifier: workspace:* + version: link:../../packages/storage + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.0 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.0) + apps/example-bot: dependencies: '@antilink-guard/core': From 120c75bff3f96efeda317bd13b037fb13d86f378 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 22:26:11 +0000 Subject: [PATCH 9/9] chore: rebuild CI for the monorepo, add CodeQL/GOVERNANCE/ROADMAP, real DB driver selection CI (.github/workflows/ci.yml): full rewrite for pnpm - install, build (all packages/apps, topologically ordered by pnpm), typecheck, lint, format check, test. Runs on Node 20 and 22 (dropped 18, matching the package.json engines field). Adds live postgres:16 and mysql:8 service containers so those storage adapter contract tests actually run in CI instead of being skipped - only mysql.test.ts still skips locally when no MYSQL_TEST_URL is set. New: .github/workflows/codeql.yml (JS/TS static analysis on push, PR, and a weekly schedule). dependabot.yml: added a docker ecosystem entry for the example bot's base image, alongside the existing npm and github-actions entries. New root docs: GOVERNANCE.md (maintainer-led model, decision-making, scope boundary) and ROADMAP.md (shipped v0.1.0 items, near-term, later/exploratory, and an explicit "out of scope" section distinguishing this OSS framework from a hosted/paid product). Updated CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, the PR template, and the issue templates for the pnpm monorepo - removed stale references to the old single-file bot and to an unrelated hosted platform's support/docs links (CODE_OF_CONDUCT.md's enforcement contact no longer points at a different product's support server; issue template links no longer reference a GitHub Discussions tab this repo doesn't have enabled). Root .env.example and package.json's `homepage` field also updated - env config is per-app now (apps/example-bot, apps/dashboard-lite each have their own .env.example), and homepage points at this repository instead of an unrelated hosted product's website. Real feature, not just docs: apps/example-bot can now select its storage backend via DATABASE_DRIVER (sqlite/mysql/postgres) instead of hardcoding SqliteStorageAdapter, so the self-hosting story for MySQL/Postgres is actually implemented, not aspirational. 8 new tests for the selection logic. 202 tests pass across the whole workspace. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MivRFgabRtQs6GhpNM3wie --- .env.example | 28 ++--- .github/ISSUE_TEMPLATE/bug_report.yml | 32 ++++-- .github/ISSUE_TEMPLATE/config.yml | 9 +- .github/ISSUE_TEMPLATE/feature_request.yml | 15 ++- .github/PULL_REQUEST_TEMPLATE.md | 7 +- .github/dependabot.yml | 38 ++++--- .github/workflows/ci.yml | 58 +++++++++-- .github/workflows/codeql.yml | 37 +++++++ .prettierignore | 4 + CODE_OF_CONDUCT.md | 6 +- CONTRIBUTING.md | 102 ++++++++++++------- GOVERNANCE.md | 62 +++++++++++ ROADMAP.md | 61 +++++++++++ SECURITY.md | 57 ++++++----- apps/example-bot/.env.example | 13 ++- apps/example-bot/README.md | 30 +++--- apps/example-bot/src/create-storage.ts | 41 ++++++++ apps/example-bot/src/index.ts | 9 +- apps/example-bot/test/create-storage.test.ts | 80 +++++++++++++++ package.json | 4 +- 20 files changed, 539 insertions(+), 154 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .prettierignore create mode 100644 GOVERNANCE.md create mode 100644 ROADMAP.md create mode 100644 apps/example-bot/src/create-storage.ts create mode 100644 apps/example-bot/test/create-storage.test.ts diff --git a/.env.example b/.env.example index 973188f..48aad72 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,8 @@ -# ───────────────────────────────────────────────────────────── -# AntiLink — environment configuration -# Copy this file to `.env` and fill in your values. -# NEVER commit your real .env file or bot token. -# ───────────────────────────────────────────────────────────── - -# Required: your Discord bot token (Developer Portal → Bot → Token) -DISCORD_TOKEN= - -# Optional: Discord webhook URL for moderation logs. -# Leave blank to disable webhook logging. -WEBHOOK_URL= - -# Optional: comma-separated channel IDs where links are always allowed. -# Example: WHITELISTED_CHANNEL_IDS=123456789012345678,234567890123456789 -WHITELISTED_CHANNEL_IDS= - -# Optional: comma-separated role IDs whose members bypass link filtering. -# Example: IGNORED_ROLE_IDS=345678901234567890 -IGNORED_ROLE_IDS= +# AntiLink Guard OSS - this is a pnpm monorepo; environment configuration is +# per-app, not shared at the repository root. +# +# - Running the bot? see apps/example-bot/.env.example +# - Running the dashboard? see apps/dashboard-lite/.env.example +# +# Copy the relevant file to a real `.env` in that same app directory and fill +# it in. Never commit a real `.env` file or bot token. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 23fb05a..e3e7b8d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,7 +1,7 @@ -name: "🐛 Bug Report" -description: Report a problem with the AntiLink open-source bot -title: "[Bug]: " -labels: ["bug", "needs-triage"] +name: '🐛 Bug Report' +description: Report a problem with AntiLink Guard OSS +title: '[Bug]: ' +labels: ['bug', 'needs-triage'] body: - type: markdown attributes: @@ -9,7 +9,7 @@ body: Thanks for taking the time to file a bug report! Please fill out the sections below so we can reproduce and fix the issue quickly. - ⚠️ **Do not** include your bot token, webhook URL, or any secrets. + ⚠️ **Do not** include your bot token, database credentials, or any secrets. - type: checkboxes id: preflight attributes: @@ -17,10 +17,24 @@ body: options: - label: I searched existing issues and this has not been reported. required: true - - label: I am running a supported Node.js version (18+). + - label: I am running a supported Node.js version (20+). required: true - label: This is not a security vulnerability (those go to SECURITY.md). required: true + - type: dropdown + id: package + attributes: + label: Affected package or app + options: + - '@antilink-guard/core' + - '@antilink-guard/storage' + - '@antilink-guard/discord-bot' + - '@antilink-guard/cli' + - 'apps/example-bot' + - 'apps/dashboard-lite' + - Not sure + validations: + required: true - type: textarea id: description attributes: @@ -55,16 +69,16 @@ body: id: node-version attributes: label: Node.js version - placeholder: "e.g. 20.11.0" + placeholder: 'e.g. 20.11.0' validations: required: true - type: input id: djs-version attributes: label: discord.js version - placeholder: "e.g. 14.x" + placeholder: 'e.g. 14.x' - type: input id: os attributes: label: Operating system - placeholder: "e.g. Ubuntu 22.04, Windows 11" + placeholder: 'e.g. Ubuntu 22.04, Windows 11' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a428819..ca965f3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,8 @@ blank_issues_enabled: false contact_links: - - name: 💬 Community & Support (Discord) - url: https://support.antil.ink/ - about: Questions, setup help, and general discussion in the AntiLink support server. - name: 📖 Documentation - url: https://docs.antil.ink/ - about: Guides and configuration reference. + url: https://github.com/timeout187/antilink-guard/tree/main/docs + about: Guides and configuration reference (getting started, self-hosting, rules engine, and more). - name: 🔒 Report a security vulnerability - url: https://github.com/timeout187/Anti-Links-Discord-Bot/security/advisories/new + url: https://github.com/timeout187/antilink-guard/security/advisories/new about: Please report vulnerabilities privately — never in public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 02e5baa..4f72c8b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,13 +1,14 @@ -name: "💡 Feature Request" -description: Suggest an idea or improvement for the open-source bot -title: "[Feature]: " -labels: ["enhancement", "needs-triage"] +name: '💡 Feature Request' +description: Suggest an idea or improvement for AntiLink Guard OSS +title: '[Feature]: ' +labels: ['enhancement', 'needs-triage'] body: - type: markdown attributes: value: | - Thanks for the suggestion! Please check the - [Roadmap](../../README.md#roadmap) first — your idea may already be planned. + Thanks for the suggestion! Please check + [ROADMAP.md](https://github.com/timeout187/antilink-guard/blob/main/ROADMAP.md) + first — your idea may already be planned. - type: checkboxes id: preflight attributes: @@ -15,8 +16,6 @@ body: options: - label: I searched existing issues and this has not been requested. required: true - - label: This relates to the open-source bot (not a hosted platform product). - required: true - type: textarea id: problem attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 18123a2..c6c851a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,6 +10,7 @@ understand and merge your change quickly. ## Related issue + Closes # ## Type of change @@ -26,12 +27,12 @@ Closes # ## Checklist -- [ ] My code follows the project's style and passes `npm run lint` +- [ ] My code follows the project's style and passes `pnpm run lint` and `pnpm run typecheck` - [ ] I have performed a self-review of my changes - [ ] I have commented non-obvious code where needed - [ ] I have updated the documentation where needed -- [ ] I have added/updated tests where applicable -- [ ] I did **not** commit any secrets (`.env`, tokens, webhook URLs) +- [ ] I have added/updated tests where applicable (`pnpm run test`) +- [ ] I did **not** commit any secrets (`.env`, tokens, database URLs) - [ ] I updated `CHANGELOG.md` under **Unreleased** if this is user-facing ## Screenshots (if applicable) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b0bfa86..6592f62 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,27 +1,37 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' open-pull-requests-limit: 10 labels: - - "dependencies" + - 'dependencies' commit-message: - prefix: "chore(deps)" - prefix-development: "chore(deps-dev)" + prefix: 'chore(deps)' + prefix-development: 'chore(deps-dev)' groups: minor-and-patch: update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' labels: - - "dependencies" - - "ci" + - 'dependencies' + - 'ci' commit-message: - prefix: "ci(deps)" + prefix: 'ci(deps)' + + - package-ecosystem: 'docker' + directory: '/apps/example-bot' + schedule: + interval: 'weekly' + labels: + - 'dependencies' + - 'docker' + commit-message: + prefix: 'chore(deps-docker)' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b89de2..0b2c78a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,33 +15,71 @@ concurrency: jobs: build: - name: Lint & Build (Node ${{ matrix.node-version }}) + name: Typecheck, lint, test & build (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - node-version: [18, 20, 22] + node-version: [20, 22] + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: antilink_guard_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: antilink_guard_test + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -proot" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRES_TEST_URL: postgres://postgres:postgres@localhost:5432/antilink_guard_test + MYSQL_TEST_URL: mysql://root:root@localhost:3306/antilink_guard_test steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up pnpm + uses: pnpm/action-setup@v4 + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: npm + cache: pnpm - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile + + - name: Build (workspace packages must build before typecheck/lint can resolve them) + run: pnpm run build + + - name: Typecheck + run: pnpm run typecheck - # The steps below use `--if-present` so the workflow stays green even if a - # given script isn't defined yet. Add the scripts to package.json to enable. - name: Lint - run: npm run lint --if-present + run: pnpm run lint - - name: Build - run: npm run build --if-present + - name: Format check + run: pnpm run format:check - name: Test - run: npm test --if-present + run: pnpm run test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3e1cbcc --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: read + security-events: write + actions: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{ matrix.language }}' diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f633d15 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +pnpm-lock.yaml +.claude/ +**/dist/ +**/coverage/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index a4f41c3..5e396bb 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -59,9 +59,9 @@ representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement in the -**[AntiLink support server](https://support.antil.ink/)** — open a private -ticket or contact a staff member/moderator directly. +reported to the project maintainers by opening a +[private GitHub Security Advisory](https://github.com/timeout187/antilink-guard/security/advisories/new) +(visible only to maintainers) or by direct message to a maintainer on GitHub. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afea5c1..5c17f7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,16 @@ -# Contributing to AntiLink +# Contributing to AntiLink Guard OSS -First off — thank you for taking the time to contribute! This is the open-source -edition of AntiLink, and it only gets better with community help. +Thank you for taking the time to contribute! AntiLink Guard OSS is a +community-maintained, self-hostable Discord anti-phishing and link +moderation framework, and it only gets better with community help. -This document explains how to propose changes. By participating, you agree to -follow our [Code of Conduct](./CODE_OF_CONDUCT.md). +By participating, you agree to follow our +[Code of Conduct](./CODE_OF_CONDUCT.md). ## Table of Contents - [Ways to Contribute](#ways-to-contribute) +- [Project Layout](#project-layout) - [Development Setup](#development-setup) - [Branching & Workflow](#branching--workflow) - [Commit Messages](#commit-messages) @@ -22,38 +24,60 @@ follow our [Code of Conduct](./CODE_OF_CONDUCT.md). You don't have to write code to help: -- 📝 Improve documentation and examples +- 📝 Improve documentation in [`docs/`](./docs) - 🐛 Report bugs with clear reproduction steps -- 💡 Suggest features (check the [Roadmap](./README.md#roadmap) first) +- 💡 Suggest features (check [ROADMAP.md](./ROADMAP.md) first) - 🧪 Add tests -- 🔧 Pick up a *Planned* roadmap item +- 🔧 Pick up a roadmap item or an issue labeled `good first issue` + +## Project Layout + +This is a pnpm workspace monorepo: + +``` +apps/ + example-bot/ a working, minimal self-hosted bot built on the packages below + dashboard-lite/ a local read-only dashboard +packages/ + core/ URL extraction, classification, and the policy engine (no discord.js) + storage/ memory / SQLite / MySQL / PostgreSQL storage adapters + discord-bot/ the discord.js v14 adapter: slash commands + moderation pipeline + cli/ the `antilink` command-line tool +docs/ user-facing documentation +``` + +`packages/core` has no runtime dependency on Discord or any storage backend - +if you're fixing detection logic, you almost always want to be here. ## Development Setup -**Prerequisites:** Node.js 18+ and npm. +**Prerequisites:** Node.js 20+ and [pnpm](https://pnpm.io) (`corepack enable` +will install the version this repo pins via `packageManager`). ```bash # Fork, then clone your fork -git clone https://github.com//Anti-Links-Discord-Bot.git -cd Anti-Links-Discord-Bot +git clone https://github.com//antilink-guard.git +cd antilink-guard -# Install dependencies -npm install +# Install dependencies for the whole workspace +pnpm install -# Copy the environment template and fill in a TEST bot's credentials -cp .env.example .env +# Build every package (required once before typecheck/lint/tests can resolve +# workspace packages like @antilink-guard/core - see Coding Standards) +pnpm run build ``` -> Always develop against a **test bot and test server** — never a production token. - -Useful scripts (see `package.json`): +To run just one package's tests while iterating: ```bash -npm start # run the bot -npm run lint # lint the codebase (if configured) -npm test # run tests (if configured) +pnpm --filter @antilink-guard/core test +pnpm --filter @antilink-guard/core test:watch ``` +To try the example bot against a real Discord test server, see +[`apps/example-bot/README.md`](./apps/example-bot/README.md) - always develop +against a **test bot and test server**, never a production token. + ## Branching & Workflow - `main` is the stable branch. Keep it releasable. @@ -78,26 +102,36 @@ Common types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`. Examples: ``` -feat(filter): support wildcard channel whitelists -fix(webhook): handle missing WEBHOOK_URL gracefully -docs(readme): clarify Message Content Intent requirement +feat(core): detect punycode homoglyph domains +fix(discord-bot): handle a null member on role-bypass check +docs(self-hosting): document the MySQL connection string format ``` ## Coding Standards -- Match the existing style; run the linter before pushing. +- This repo uses **strict TypeScript**, ESLint (flat config), and Prettier. + Run `pnpm run lint` and `pnpm run format:check` before pushing. +- Workspace packages resolve each other via their built `dist/` output, not + TypeScript project references - if you change a package that others + depend on (most commonly `@antilink-guard/core`), run `pnpm run build` + before typechecking or testing dependent packages, or their editors/CI + will report stale or missing types. - Prefer small, well-named functions over large blocks. -- Never log or commit secrets. `.env` is gitignored — keep it that way. -- Document non-obvious behavior with brief comments. -- Don't introduce features that aren't discussed in an issue first for larger changes. +- Never log or commit secrets. `.env` files are gitignored - keep it that way. +- No hardcoded "known phishing domain" lists anywhere in this codebase. That + data is always guild-supplied configuration, never a bundled database this + project doesn't actually maintain. +- Don't introduce features that aren't discussed in an issue first for + larger changes. ## Pull Requests 1. Ensure your branch is up to date with `main`. -2. Run linting/tests locally and make sure they pass. +2. Run `pnpm run typecheck`, `pnpm run lint`, and `pnpm run test` locally and + make sure they pass. 3. Fill out the pull request template completely. 4. Link the issue your PR resolves (e.g. `Closes #123`). -5. Be responsive to review feedback — we aim to keep reviews friendly and quick. +5. Be responsive to review feedback - we aim to keep reviews friendly and quick. A maintainer will review as soon as they can. Please be patient; this is community-maintained. @@ -108,13 +142,13 @@ Open a [bug report][issues] using the template. Include: - What you expected vs. what happened - Steps to reproduce -- Node.js and discord.js versions +- Node.js version and which package/app is affected - Relevant logs (with secrets redacted) ## Suggesting Features Open a [feature request][issues]. Explain the problem you're trying to solve, -not just the solution — that helps us find the best fit for the project. +not just the solution - that helps us find the best fit for the project. ## Security Issues @@ -123,6 +157,6 @@ not just the solution — that helps us find the best fit for the project. --- -Thanks again for contributing! 💜 +Thanks again for contributing! -[issues]: https://github.com/timeout187/Anti-Links-Discord-Bot/issues +[issues]: https://github.com/timeout187/antilink-guard/issues diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..e2bab5b --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,62 @@ +# Governance + +AntiLink Guard OSS is a community-maintained open-source project. This +document describes how decisions get made today, and how that's expected to +evolve as the project and its contributor base grow. + +## Current model: maintainer-led + +Right now, the project has a small set of maintainers (starting with the +repository owner) who: + +- Review and merge pull requests +- Triage issues and label them +- Cut releases and maintain `CHANGELOG.md` +- Set direction via [`ROADMAP.md`](./ROADMAP.md) + +Maintainers make final decisions on scope and direction, but design +discussions happen in the open on GitHub issues and pull requests - not +privately. Anyone is welcome to propose changes, including changes to how +the project is governed. + +## Becoming a maintainer + +There's no formal application process yet. In practice, consistent, +high-quality contributions (code review, well-scoped PRs, thoughtful issue +triage) over time are how existing maintainers identify and invite new +maintainers. As the contributor base grows, this document will be updated +with more concrete criteria. + +## Decision-making + +- **Small changes** (bug fixes, docs, dependency bumps): a single + maintainer's review and approval is enough to merge. +- **Larger changes** (new packages, breaking API changes, changes to the + detection/scoring model in `packages/core`): should be discussed in an + issue before a PR is opened, so design disagreements surface before code + is written. +- **Disagreements** are resolved by discussion first. If maintainers cannot + reach consensus, the repository owner makes the final call, but this is + meant to be rare - most decisions should be uncontroversial once discussed. + +## Scope boundary + +This project is deliberately **the open-source, self-hostable framework +only**. Maintainers are not obligated to add features that only make sense +for a hosted, multi-tenant, or paid service (billing, OAuth-based +multi-admin dashboards, managed infrastructure). Those are out of scope by +design, not by oversight - see the "background" note in +[`README.md`](./README.md) for context on why. + +## Code of Conduct + +All participation in this project - issues, pull requests, reviews, +discussions - is governed by [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md). +Maintainers are responsible for enforcing it fairly and consistently. + +## Changing this document + +Governance changes are proposed the same way as any other change: open a +pull request. Given the project's current size, meaningful changes to this +document should be flagged clearly in the PR description so they get +visibility beyond a routine review. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..37fbcf4 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,61 @@ +# Roadmap + +Direction for **AntiLink Guard OSS**, the self-hostable framework in this +repository. Unchecked items are not shipped yet. Have an idea that isn't +here? Open a [feature request][issues]. + +## Shipped (v0.1.0) + +- [x] `packages/core` - URL/invite extraction (including defanged links, + punycode, zero-width-character obfuscation, markdown links), domain + allow/blocklist and known-phishing classification, URL shortener and + homoglyph detection, custom regex rules, and the + ALLOW/WARN/BLOCK/QUARANTINE policy engine +- [x] `packages/storage` - memory, SQLite (default), MySQL, and PostgreSQL + adapters behind one interface, plus config export/import bundles +- [x] `packages/discord-bot` - the discord.js v14 adapter: all slash + commands, the message moderation pipeline, permission-gated + delete/timeout, metadata-only audit logging, mod-log embeds, and a + moderation-action rate limiter +- [x] `packages/cli` - the `antilink` command (`scan`, `test-url`, `init`, + `export-config`, `import-config`, `doctor`) +- [x] `apps/example-bot` - a working self-hosted example with Docker Compose +- [x] `apps/dashboard-lite` - a minimal local read-only dashboard + +## Near-term + +- [ ] Per-channel exemption management via slash commands (the policy + engine already supports per-channel rules; there's no `/channel-rule` + command yet) +- [ ] Configurable timeout duration (currently a fixed 10-minute default) +- [ ] A public, opt-in domain reputation feed format that + `knownPhishingDomains` can be populated from, so servers can share + blocklists without this project bundling one itself +- [ ] Structured, queryable audit log filtering (by user, channel, verdict, + date range) in `dashboard-lite` +- [ ] A test suite that runs `packages/discord-bot`'s message pipeline + against recorded fixture message payloads for regression coverage + beyond unit-level fakes + +## Later / exploratory + +- [ ] Optional Discord OAuth login for `dashboard-lite`, clearly documented + as opt-in (see its README's "no authentication" note) - not a + prerequisite for using the dashboard locally +- [ ] Multi-guild management in a single dashboard session +- [ ] A plugin/hook interface in `packages/core` for custom detection rules + beyond regex (e.g. calling an external API) +- [ ] Sharding guidance/support in `packages/discord-bot` for very large + self-hosted deployments + +## Explicitly out of scope for this project + +This framework is deliberately the **open-source, self-hostable** piece. +The following are different products/concerns and won't be built here: + +- Billing, subscriptions, or license management +- Managed hosting or multi-tenant infrastructure +- A hosted, authenticated, multi-admin web dashboard (that's a different + product; `dashboard-lite` stays local and read-only by design) + +[issues]: https://github.com/timeout187/antilink-guard/issues diff --git a/SECURITY.md b/SECURITY.md index 6d0e973..aed6911 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,36 +1,34 @@ # Security Policy -The AntiLink team takes the security of this project and the servers that run it -seriously. Thank you for helping keep the community safe. +The AntiLink Guard OSS maintainers take the security of this project and the +servers that run it seriously. Thank you for helping keep the community safe. ## Supported Versions -Security fixes are provided for the latest release on the `main` branch. Older -snapshots are not maintained. +Security fixes are provided for the latest release on the `main` branch. +Older tags are not maintained. -| Version | Supported | -| ------- | --------- | -| `main` (latest) | ✅ | -| Older tags | ❌ | +| Version | Supported | +| --------------- | --------- | +| `main` (latest) | ✅ | +| Older tags | ❌ | ## Reporting a Vulnerability **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** -Instead, use one of the following private channels: - -1. **GitHub Security Advisories** (preferred) — open a private report via the - repository's **Security → Report a vulnerability** tab. -2. **AntiLink support server** — open a **private ticket** at - and ask staff to escalate. **Do not** post - vulnerability details in public channels. +Instead, use **GitHub Security Advisories**: open a private report via this +repository's **Security → Report a vulnerability** tab. This is the +preferred and fastest channel - only maintainers can see the report until +you agree to disclose it. Please include, where possible: - A description of the vulnerability and its impact - Steps to reproduce or a proof of concept -- Affected version/commit +- The affected package (`core`, `storage`, `discord-bot`, `cli`) or app + (`example-bot`, `dashboard-lite`) and version/commit - Any suggested remediation ## What to Expect @@ -39,18 +37,29 @@ Please include, where possible: - An assessment and, if confirmed, a plan and timeline for a fix. - Credit in the release notes if you'd like it (let us know your preference). -We ask that you give us a reasonable opportunity to address the issue before any -public disclosure. We support coordinated disclosure and will work with you on -timing. +We ask that you give us a reasonable opportunity to address the issue before +any public disclosure. We support coordinated disclosure and will work with +you on timing. + +## Threat Model + +See [`docs/threat-model.md`](./docs/threat-model.md) for what this project +does and does not defend against, and what data it stores. ## Scope -This policy covers the open-source bot in this repository. Vulnerabilities in -the separate, hosted AntiLink platform products should be reported through the -channels published on the [AntiLink website](https://antil.ink). +This policy covers the packages and apps in this repository. This is an +independent open-source project; it is not the reporting channel for any +unrelated hosted or commercial Discord bot product, even one with a similar +name. ## Good Practices for Self-Hosters - Never commit your `.env` or bot token. Rotate any leaked token immediately. -- Grant the bot only the permissions it needs (Manage Messages, View Channels). -- Keep Node.js and dependencies up to date (Dependabot is enabled in this repo). +- Grant the bot only the permissions it needs (View Channels, Send Messages, + Manage Messages, and Moderate Members only if you use `timeout` mode). +- `apps/dashboard-lite` has **no authentication** - never expose it to the + public internet (see its README). +- Keep Node.js and dependencies up to date - Dependabot is enabled for npm, + GitHub Actions, and the example bot's Docker base image. +- CodeQL static analysis runs on every push and pull request to `main`. diff --git a/apps/example-bot/.env.example b/apps/example-bot/.env.example index 8f7f752..a9cf016 100644 --- a/apps/example-bot/.env.example +++ b/apps/example-bot/.env.example @@ -13,8 +13,19 @@ DISCORD_CLIENT_ID= # blank) can take up to an hour to propagate. DISCORD_GUILD_ID= -# SQLite is the default, zero-setup storage backend for self-hosting. +# Storage backend: sqlite (default, zero-setup), mysql, or postgres. +DATABASE_DRIVER=sqlite + +# Used when DATABASE_DRIVER=sqlite (the default). DATABASE_SQLITE_PATH=./data/antilink.sqlite +# Used when DATABASE_DRIVER=mysql. Example: +# DATABASE_MYSQL_URL=mysql://user:password@localhost:3306/antilink_guard +DATABASE_MYSQL_URL= + +# Used when DATABASE_DRIVER=postgres. Example: +# DATABASE_POSTGRES_URL=postgres://user:password@localhost:5432/antilink_guard +DATABASE_POSTGRES_URL= + # Log verbosity: trace, debug, info, warn, error, silent LOG_LEVEL=info diff --git a/apps/example-bot/README.md b/apps/example-bot/README.md index 9a1f431..7b0eb94 100644 --- a/apps/example-bot/README.md +++ b/apps/example-bot/README.md @@ -30,13 +30,16 @@ cp apps/example-bot/.env.example apps/example-bot/.env Edit `apps/example-bot/.env`: -| Variable | Required | Description | -| ---------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `DISCORD_TOKEN` | Yes | Your bot's token. | -| `DISCORD_CLIENT_ID` | For command registration | Your application's ID. | -| `DISCORD_GUILD_ID` | No | Register commands to one server for instant updates during development. Leave blank to register globally (takes up to ~1 hour to propagate). | -| `DATABASE_SQLITE_PATH` | No | Defaults to `./data/antilink.sqlite`. | -| `LOG_LEVEL` | No | `trace`\|`debug`\|`info`\|`warn`\|`error`\|`silent`. Defaults to `info`. | +| Variable | Required | Description | +| ----------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_TOKEN` | Yes | Your bot's token. | +| `DISCORD_CLIENT_ID` | For command registration | Your application's ID. | +| `DISCORD_GUILD_ID` | No | Register commands to one server for instant updates during development. Leave blank to register globally (takes up to ~1 hour to propagate). | +| `DATABASE_DRIVER` | No | `sqlite` (default), `mysql`, or `postgres`. | +| `DATABASE_SQLITE_PATH` | If using SQLite | Defaults to `./data/antilink.sqlite`. | +| `DATABASE_MYSQL_URL` | If using MySQL | e.g. `mysql://user:password@localhost:3306/antilink_guard`. | +| `DATABASE_POSTGRES_URL` | If using PostgreSQL | e.g. `postgres://user:password@localhost:5432/antilink_guard`. | +| `LOG_LEVEL` | No | `trace`\|`debug`\|`info`\|`warn`\|`error`\|`silent`. Defaults to `info`. | ## 3. Register slash commands @@ -71,13 +74,14 @@ configuration survives container restarts and rebuilds. ## Storage -This example uses **SQLite** (via `@antilink-guard/storage`'s -`SqliteStorageAdapter`) - a single file, no separate database server to run. -For MySQL or PostgreSQL, swap the adapter construction in `src/index.ts`; see +Defaults to **SQLite** - a single file, no separate database server to run. +Set `DATABASE_DRIVER=mysql` or `DATABASE_DRIVER=postgres` (with the matching +`DATABASE_MYSQL_URL` / `DATABASE_POSTGRES_URL`) to use a real database server +instead; see [`src/create-storage.ts`](./src/create-storage.ts) and [`docs/self-hosting.md`](../../docs/self-hosting.md). ## What this example does and does not include -- Includes: the full moderation pipeline, all slash commands, mod-log embeds. -- Does not include: a web dashboard (see `apps/dashboard-lite`), multi-shard - scaling, or any of the hosted AntiLink platform's paid features. +- Includes: the full moderation pipeline and all slash commands. +- Does not include: a web dashboard (see `apps/dashboard-lite`) or multi-shard + scaling - see [ROADMAP.md](../../ROADMAP.md) for what's planned. diff --git a/apps/example-bot/src/create-storage.ts b/apps/example-bot/src/create-storage.ts new file mode 100644 index 0000000..5288dfd --- /dev/null +++ b/apps/example-bot/src/create-storage.ts @@ -0,0 +1,41 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { + MysqlStorageAdapter, + PostgresStorageAdapter, + SqliteStorageAdapter, + type StorageAdapter, +} from '@antilink-guard/storage'; + +export interface StorageEnv { + DATABASE_DRIVER?: string; + DATABASE_SQLITE_PATH?: string; + DATABASE_MYSQL_URL?: string; + DATABASE_POSTGRES_URL?: string; +} + +export function createStorageFromEnv(env: StorageEnv): StorageAdapter { + const driver = (env.DATABASE_DRIVER ?? 'sqlite').toLowerCase(); + + if (driver === 'mysql') { + if (!env.DATABASE_MYSQL_URL) { + throw new Error('DATABASE_DRIVER=mysql requires DATABASE_MYSQL_URL to be set.'); + } + return new MysqlStorageAdapter({ uri: env.DATABASE_MYSQL_URL }); + } + + if (driver === 'postgres' || driver === 'postgresql') { + if (!env.DATABASE_POSTGRES_URL) { + throw new Error('DATABASE_DRIVER=postgres requires DATABASE_POSTGRES_URL to be set.'); + } + return new PostgresStorageAdapter({ connectionString: env.DATABASE_POSTGRES_URL }); + } + + if (driver !== 'sqlite') { + throw new Error(`Unknown DATABASE_DRIVER "${driver}". Expected sqlite, mysql, or postgres.`); + } + + const dbPath = resolve(env.DATABASE_SQLITE_PATH ?? './data/antilink.sqlite'); + mkdirSync(dirname(dbPath), { recursive: true }); + return new SqliteStorageAdapter({ filename: dbPath }); +} diff --git a/apps/example-bot/src/index.ts b/apps/example-bot/src/index.ts index fbc19e5..e5986bd 100644 --- a/apps/example-bot/src/index.ts +++ b/apps/example-bot/src/index.ts @@ -1,8 +1,6 @@ import 'dotenv/config'; -import { mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { SqliteStorageAdapter } from '@antilink-guard/storage'; import { createBot } from '@antilink-guard/discord-bot'; +import { createStorageFromEnv } from './create-storage.js'; const token = process.env.DISCORD_TOKEN; if (!token) { @@ -10,10 +8,7 @@ if (!token) { process.exit(1); } -const dbPath = resolve(process.env.DATABASE_SQLITE_PATH ?? './data/antilink.sqlite'); -mkdirSync(dirname(dbPath), { recursive: true }); - -const storage = new SqliteStorageAdapter({ filename: dbPath }); +const storage = createStorageFromEnv(process.env); await storage.init(); const client = createBot({ storage }); diff --git a/apps/example-bot/test/create-storage.test.ts b/apps/example-bot/test/create-storage.test.ts new file mode 100644 index 0000000..00d2b91 --- /dev/null +++ b/apps/example-bot/test/create-storage.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + MysqlStorageAdapter, + PostgresStorageAdapter, + SqliteStorageAdapter, +} from '@antilink-guard/storage'; +import { createStorageFromEnv } from '../src/create-storage.js'; + +describe('createStorageFromEnv', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('defaults to SQLite when DATABASE_DRIVER is unset', async () => { + const dir = await mkdtemp(join(tmpdir(), 'example-bot-storage-')); + tempDirs.push(dir); + + const storage = createStorageFromEnv({ DATABASE_SQLITE_PATH: join(dir, 'db.sqlite') }); + expect(storage).toBeInstanceOf(SqliteStorageAdapter); + await storage.close(); + }); + + it('creates the SQLite data directory if it does not exist yet', async () => { + const dir = await mkdtemp(join(tmpdir(), 'example-bot-storage-')); + tempDirs.push(dir); + const dbPath = join(dir, 'nested', 'subdir', 'db.sqlite'); + + const storage = createStorageFromEnv({ + DATABASE_DRIVER: 'sqlite', + DATABASE_SQLITE_PATH: dbPath, + }); + await storage.init(); + await storage.close(); + }); + + it('creates a MysqlStorageAdapter when configured', () => { + const storage = createStorageFromEnv({ + DATABASE_DRIVER: 'mysql', + DATABASE_MYSQL_URL: 'mysql://user:pass@localhost:3306/db', + }); + expect(storage).toBeInstanceOf(MysqlStorageAdapter); + }); + + it('throws when DATABASE_DRIVER=mysql is missing its URL', () => { + expect(() => createStorageFromEnv({ DATABASE_DRIVER: 'mysql' })).toThrow(/DATABASE_MYSQL_URL/); + }); + + it('creates a PostgresStorageAdapter when configured', () => { + const storage = createStorageFromEnv({ + DATABASE_DRIVER: 'postgres', + DATABASE_POSTGRES_URL: 'postgres://user:pass@localhost:5432/db', + }); + expect(storage).toBeInstanceOf(PostgresStorageAdapter); + }); + + it('accepts "postgresql" as an alias for the driver name', () => { + const storage = createStorageFromEnv({ + DATABASE_DRIVER: 'postgresql', + DATABASE_POSTGRES_URL: 'postgres://user:pass@localhost:5432/db', + }); + expect(storage).toBeInstanceOf(PostgresStorageAdapter); + }); + + it('throws when DATABASE_DRIVER=postgres is missing its URL', () => { + expect(() => createStorageFromEnv({ DATABASE_DRIVER: 'postgres' })).toThrow( + /DATABASE_POSTGRES_URL/, + ); + }); + + it('throws on an unrecognized driver name', () => { + expect(() => createStorageFromEnv({ DATABASE_DRIVER: 'mongodb' })).toThrow( + /Unknown DATABASE_DRIVER/, + ); + }); +}); diff --git a/package.json b/package.json index 7b9b375..aa1e148 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "engines": { "node": ">=20" }, - "homepage": "https://antil.ink", + "homepage": "https://github.com/timeout187/antilink-guard#readme", "repository": { "type": "git", "url": "git+https://github.com/timeout187/antilink-guard.git" @@ -18,7 +18,7 @@ "url": "https://github.com/timeout187/antilink-guard/issues" }, "scripts": { - "build": "pnpm -r --filter \"./packages/*\" build", + "build": "pnpm -r run build", "typecheck": "pnpm -r typecheck", "lint": "eslint .", "lint:fix": "eslint . --fix",