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 4c35a15..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://antil.ink/docs - 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/.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/.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/.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/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/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/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 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/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/apps/example-bot/.env.example b/apps/example-bot/.env.example new file mode 100644 index 0000000..a9cf016 --- /dev/null +++ b/apps/example-bot/.env.example @@ -0,0 +1,31 @@ +# 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= + +# 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/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..7b0eb94 --- /dev/null +++ b/apps/example-bot/README.md @@ -0,0 +1,87 @@ +# 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_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 + +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 + +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 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/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/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 new file mode 100644 index 0000000..e5986bd --- /dev/null +++ b/apps/example-bot/src/index.ts @@ -0,0 +1,15 @@ +import 'dotenv/config'; +import { createBot } from '@antilink-guard/discord-bot'; +import { createStorageFromEnv } from './create-storage.js'; + +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 storage = createStorageFromEnv(process.env); +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/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/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/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..aa1e148 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,48 @@ { - "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", + "homepage": "https://github.com/timeout187/antilink-guard#readme", "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" - }, - "license": "MIT", - "engines": { - "node": ">=18" + "url": "https://github.com/timeout187/antilink-guard/issues" }, - "dependencies": { - "discord.js": "^14.26.4", - "dotenv": "^16.4.5" + "scripts": { + "build": "pnpm -r run 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", + "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..6f0469d --- /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', 'warn', 'delete', 'timeout']), +}); + +export const policyConfigFileSchema = z.object({ + enabled: z.boolean().default(true), + 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([]), + 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/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..7d7709f --- /dev/null +++ b/packages/core/src/policy/policy-engine.ts @@ -0,0 +1,93 @@ +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 (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'; +} + +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..6c064f6 --- /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' | 'warn' | 'delete' | 'timeout'; + +export type ModerationActionType = 'NONE' | 'LOG' | 'WARN' | '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..5287668 --- /dev/null +++ b/packages/core/test/policy-engine.test.ts @@ -0,0 +1,188 @@ +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('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, + 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/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/package.json b/packages/storage/package.json new file mode 100644 index 0000000..a7be29d --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,39 @@ +{ + "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", + "zod": "^3.24.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/config-bundle.ts b/packages/storage/src/config-bundle.ts new file mode 100644 index 0000000..3e52eb1 --- /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', 'warn', '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 new file mode 100644 index 0000000..bea147f --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,16 @@ +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'; +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/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/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/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 new file mode 100644 index 0000000..3ed41ca --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3311 @@ +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) + + 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': + 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': + 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': + 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/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': + 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 + 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 + '@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: + + '@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'} + 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==} + + '@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] + 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] + + '@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==} + + '@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==} + + '@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} + 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==} + + '@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: + 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'} + + 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'} + + 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} + + 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==} + + brace-expansion@5.0.7: + 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} + 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'} + + 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'} + + 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'} + + 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 + + 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'} + + 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'} + + 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==} + + 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'} + + 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'} + + 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'} + + 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'} + + 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==} + + 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'} + + 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'} + + 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'} + + 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'} + + 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'} + + is-glob@4.0.3: + 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==} + + 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==} + + 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==} + + 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-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==} + + 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} + + 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'} + + 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==} + + 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'} + + 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==} + + picomatch@4.0.5: + 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'} + + 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} + + 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'} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + 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==} + + punycode@2.3.1: + 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 + + 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'} + + 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'} + + 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 + + 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==} + + 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==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + 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'} + + source-map@0.7.6: + 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'} + + 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'} + + 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'} + + 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==} + + 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==} + + 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'} + 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 + + 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'} + + 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==} + + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + + 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} + 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'} + + 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'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +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 + + '@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 + + '@pinojs/redact@0.4.0': {} + + '@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 + + '@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 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.20.0': + 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 + + '@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 + '@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 + + '@vladfrangu/async_event_emitter@2.4.7': {} + + 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: {} + + atomic-sleep@1.0.0: {} + + 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 + concat-map: 0.0.1 + + brace-expansion@5.0.7: + 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 + 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 + + chownr@1.1.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@13.1.0: {} + + 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 + + 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: {} + + 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 + + dotenv@16.6.1: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + 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: {} + + expand-template@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 + + file-uri-to-path@1.0.0: {} + + 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: {} + + 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 + + globals@14.0.0: {} + + globals@16.5.0: {} + + 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: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + 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: {} + + 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: {} + + 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 + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + 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: {} + + 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: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + 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: {} + + 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: {} + + 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: + 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 + + 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: {} + + process-warning@5.0.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + 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: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + 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 + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + 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 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + 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: + 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 + + 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 + + thenify@3.3.1: + 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: {} + + 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: {} + + 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) + 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 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + 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: {} + + undici@6.24.1: {} + + uri-js@4.4.1: + 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 + 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: {} + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xtend@4.0.2: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} 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..058e3bd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +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'], + }, + }, +});