diff --git a/.contributerc.json b/.contributerc.json deleted file mode 100644 index 24c3fee..0000000 --- a/.contributerc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "workflow": "clean-flow", - "role": "maintainer", - "mainBranch": "main", - "devBranch": "dev", - "upstream": "upstream", - "origin": "origin", - "branchPrefixes": [ - "feature", - "fix", - "docs", - "chore", - "test", - "refactor" - ], - "commitConvention": "clean-commit" -} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index b469ad3..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Build - -on: - pull_request: - branches: [dev, main] - push: - branches: [dev] - -jobs: - test: - name: Run Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.13 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '22' - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Type checking - run: bun run type-check - - - name: Run tests - run: bun run test - - - name: Generate coverage - run: bun run test:coverage - continue-on-error: true - - build: - name: Build Container Images - runs-on: ubuntu-latest - needs: test - permissions: - contents: read - packages: write - pull-requests: write - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Build and Push Container - uses: wgtechlabs/container-build-flow-action@v1.7.1 - with: - # Registry Configuration - registry: both - dockerhub-username: ${{ secrets.DOCKER_HUB_USERNAME }} - dockerhub-token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - # Branch Configuration - main-branch: main - dev-branch: dev - - # Image Configuration - image-name: unthread-webhook-server - dockerfile: ./Dockerfile - context: . - platforms: linux/amd64 - - # Build Arguments - build-args: | - NODE_VERSION=22.22-alpine3.23 - RAILWAY_SERVICE_ID=${{ secrets.RAILWAY_SERVICE_ID }} - - # Labels - labels: | - org.opencontainers.image.title=Unthread Webhook Server - org.opencontainers.image.description=A reliable, production-ready Node.js server for processing Unthread.io webhooks with signature verification and smart platform handling. - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.licenses=GPL-3.0 - - # Features - pr-comment-enabled: true - cache-enabled: true - provenance: true - sbom: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..03eca0e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +on: + workflow_call: + workflow_dispatch: + +concurrency: + group: CI-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Lint Test Validate (Node.js ${{ matrix.node-version }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node-version: [22, 24, 26] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect secrets + uses: gitleaks/gitleaks-action@v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache Bun dependencies + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bunx biome check . --reporter=github + + - name: Type checking + run: bun run type-check + + - name: Run tests + run: bun run test + + - name: Generate coverage + run: bun run test:coverage + + - name: Build TypeScript + run: bun run build + + - name: Node.js runtime smoke check + run: | + node <<'EOF' + const { + generateSignature, + verifySignature, + } = require('./dist/utils/signature.js'); + const payload = '{"event":"message"}'; + const secret = 'ci-secret'; + const signature = generateSignature(payload, secret); + if (!verifySignature(payload, signature, secret)) { + throw new Error('Node runtime smoke check failed'); + } + console.log('Node runtime smoke check passed on', process.version); + EOF + + - name: Test Docker build (no push) + run: | + echo "Testing Docker build..." + docker build -t test-build . + echo "Build successful, cleaning up..." + docker image rm test-build + echo "Docker build test completed" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4fa024f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,34 @@ +name: CodeQL + +on: + schedule: + - cron: "0 6 * * 1" + workflow_call: + workflow_dispatch: + +concurrency: + group: CodeQL-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + analyze: + name: Analyze (TypeScript) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: javascript-typescript + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:javascript-typescript diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 255cf90..0c04828 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -1,55 +1,54 @@ name: Container on: + pull_request: + branches: [ dev, main ] + push: + branches: [ dev, main ] release: - types: [published] + types: [ published ] + workflow_dispatch: + +concurrency: + group: Container-${{ github.ref }} + cancel-in-progress: true jobs: - build-production: - name: Build Production Images + ci: + name: CI Gate + uses: ./.github/workflows/ci.yml + secrets: inherit + permissions: + contents: read + + codeql: + name: CodeQL Gate + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + actions: read + + build: + name: Build Container Images runs-on: ubuntu-latest + needs: [ ci, codeql ] permissions: contents: read packages: write security-events: write - + pull-requests: write + steps: - name: Checkout code uses: actions/checkout@v6 - - name: Build and Push Production Container - uses: wgtechlabs/container-build-flow-action@v1.7.1 + - name: Build and Push Container + uses: wgtechlabs/container-build-flow-action@v1.8.1 with: - # Registry Configuration registry: both dockerhub-username: ${{ secrets.DOCKER_HUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - # Branch Configuration - main-branch: main - dev-branch: dev - - # Image Configuration - image-name: unthread-webhook-server - dockerfile: ./Dockerfile - context: . - platforms: linux/amd64,linux/arm64 + ghcr-token: ${{ github.token }} + floating-tags: true release-platforms: linux/amd64 - - # Build Arguments - build-args: | - NODE_VERSION=22.22-alpine3.23 - RAILWAY_SERVICE_ID=${{ secrets.RAILWAY_SERVICE_ID }} - - # Labels - labels: | - org.opencontainers.image.title=Unthread Webhook Server - org.opencontainers.image.description=A reliable, production-ready Node.js server for processing Unthread.io webhooks with signature verification and smart platform handling. - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.licenses=GPL-3.0 - - # Features - cache-enabled: true - provenance: true - sbom: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf42abd..8b131fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,14 +2,36 @@ name: Release on: push: - branches: [main] + branches: [ main ] + workflow_dispatch: + +concurrency: + group: Release-${{ github.ref }} + cancel-in-progress: false jobs: + ci: + name: CI Gate + uses: ./.github/workflows/ci.yml + secrets: inherit + permissions: + contents: read + + codeql: + name: CodeQL Gate + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + actions: read + release: name: Create Release runs-on: ubuntu-latest + needs: [ ci, codeql ] permissions: contents: write + pull-requests: write steps: - name: Checkout code @@ -17,10 +39,18 @@ jobs: with: fetch-depth: 0 + - name: Verify GH_PAT is set + env: + GH_PAT: ${{ secrets.GH_PAT }} + run: | + if [ -z "$GH_PAT" ]; then + echo "::error::GH_PAT secret is required." + exit 1 + fi + - name: Create Release id: release uses: wgtechlabs/release-build-flow-action@v1.7.0 with: - # Use PAT so the release event triggers downstream workflows - # (e.g., container build flow) github-token: ${{ secrets.GH_PAT }} + commit-convention: clean-commit diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index e87978b..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Validate - -on: - pull_request: - branches: [dev, main] - -jobs: - validate: - name: Validate Changes - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.13 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '22' - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Type checking - run: bun run type-check - - - name: Run tests - run: bun run test - - - name: Generate coverage - run: bun run test:coverage - continue-on-error: true - - - name: Build TypeScript - run: bun run build - - - name: Test Docker build (no push) - run: | - echo "Testing Docker build..." - docker build -t test-build . - echo "Build successful, cleaning up..." - docker image rm test-build - echo "✅ Docker build test completed" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b3a1f6c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,12 @@ +# Gitleaks configuration +# https://github.com/gitleaks/gitleaks#configuration + +title = "Gitleaks - Unthread Webhook Server" + +[allowlist] +description = "Known false positives" +paths = [ + # Example env files contain placeholder values, not real secrets + '''.env.example''', + '''.env.railway''', +] diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 33f1903..105207a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,7 @@ { "recommendations": [ - "dbaeumer.vscode-eslint", - "vitest.explorer", - "bierner.markdown-preview-github-styles", + "biomejs.biome", + "bierner.markdown-preview-github-styles", "bierner.github-markdown-preview", "bierner.markdown-shiki", "bierner.color-info", diff --git a/.vscode/settings.json b/.vscode/settings.json index b8e8b49..31b890b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,25 +1,17 @@ { - "editor.formatOnSave": false, + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" }, - "eslint.validate": [ - "javascript", - "typescript" - ], - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ], - "eslint.lintTask.enable": true, "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "[typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "biomejs.biome" }, "python-envs.defaultEnvManager": "ms-python.python:system", "python-envs.pythonProjects": [] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 093ee10..fe2a5c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,38 +19,67 @@ If you can write code then create a pull request to this repo and I will review To get started with development: 1. **Fork and clone the repository** + ```bash git clone https://github.com/your-username/unthread-webhook-server.git cd unthread-webhook-server ``` 2. **Install dependencies** + ```bash bun install ``` + > ⚠️ **Important**: This project currently uses Bun as the package manager. -3. **Set up environment variables** +3. **Install contribute-now (recommended)** + + ```bash + bunx contribute-now setup + ``` + + > 📝 **About contribute-now**: This project uses [contribute-now](https://github.com/warengonzaga/contribute-now) to automate and standardize the git workflow. It helps enforce the Clean Flow workflow and Clean Commit conventions. You can also install it globally with `bun install -g contribute-now` or use `cn` alias for convenience. + +4. **Install GitHub CLI (optional but recommended)** + + ```bash + # macOS + brew install gh + + # Windows + choco install gh + + # Linux + apt-get install gh + ``` + + > 📝 **About GitHub CLI**: Required for full `contribute-now` functionality, especially for PR creation and role detection. + +5. **Set up environment variables** - Copy `.env.example` to `.env` - Fill in the required information as described in the README + ```bash cp .env.example .env ``` -4. **Start Redis** +6. **Start Redis** + ```bash # Choose one option based on your setup redis-server # Local installation brew services start redis # macOS sudo systemctl start redis-server # Linux docker run -d -p 6379:6379 redis:alpine # Docker - + # OR for full Docker setup with proper naming: docker network create unthread-integration-network docker-compose up -d redis-webhook ``` -5. **Start the project in development mode** +7. **Start the project in development mode** + ```bash bun run dev ``` @@ -89,7 +118,7 @@ bun run test:coverage # Generate coverage report #### 🏛️ Project Structure -``` +```text src/ ├── app.ts # Main application entry point ├── config/ # Configuration files @@ -117,9 +146,10 @@ src/ - **Error Handling**: Implement comprehensive error handling with detailed logging - **Package Manager**: Use Bun for dependency management and project scripts - **Code Style**: Follow existing patterns and maintain consistency -- **Environment**: Use Node.js 22+ for development +- **Environment**: Use Node.js 26 as default runtime (Node.js 22, 24, and 26 are supported) - **Redis Integration**: Ensure Redis connectivity for all webhook-related features - **Webhook Integration**: Ensure compatibility with [`wgtechlabs/unthread-telegram-bot`](https://github.com/wgtechlabs/unthread-telegram-bot) +- **Architecture**: Remember this server is **one per platform**. Each instance has a single `TARGET_PLATFORM` (discord, telegram, whatsapp, etc.) and routes events to that platform's Redis queue. Do not add multi-platform routing logic to a single server instance. #### 🔍 Code Quality and Linting @@ -152,6 +182,7 @@ bun run lint:ci **Comprehensive ESLint Configuration:** This project uses a modern flat config format (`eslint.config.js`) with the following capabilities: + - **TypeScript-first**: Full TypeScript-ESLint integration with strict type checking - **Security-focused**: Multiple security plugins working together to prevent vulnerabilities - **Customizable**: Tailored rules for webhook server security requirements @@ -195,6 +226,7 @@ const data: any = response; This project uses [Bun's built-in test runner](https://bun.com/docs/cli/test) (`bun:test`) for automated testing. When contributing: **Automated Testing:** + - Write tests for new features and bug fixes - Ensure all tests pass: `bun run test` - Maintain minimum 80% code coverage: `bun run test:coverage` @@ -202,6 +234,7 @@ This project uses [Bun's built-in test runner](https://bun.com/docs/cli/test) (` - Use `bun run test:watch` for development **Manual Testing:** + - Test your changes using tools like ngrok for webhook testing - Verify Redis connectivity and queue operations - Test HMAC signature verification with valid Unthread events @@ -233,6 +266,7 @@ This project uses [Bun's built-in test runner](https://bun.com/docs/cli/test) (` ### 📖 Documentation Improvements to documentation are always welcome! This includes: + - README updates - Code comments - API documentation @@ -245,6 +279,7 @@ Improvements to documentation are always welcome! This includes: For any security bugs or issues, please create a private security advisory through GitHub's security advisory feature. For other bugs, please create an issue with: + - Clear description of the problem - Steps to reproduce - Expected vs actual behavior @@ -285,22 +320,22 @@ This project uses [`@wgtechlabs/log-engine`](https://github.com/wgtechlabs/log-e **Custom Enterprise Protection:** ```javascript -import { LogEngine } from '@wgtechlabs/log-engine'; +import { LogEngine } from "@wgtechlabs/log-engine"; // Add custom patterns for enterprise-specific data LogEngine.addCustomRedactionPatterns([ - /internal.*/i, // Matches any field starting with "internal" - /company.*/i, // Matches any field starting with "company" - /webhook.*/i, // Matches webhook-specific fields - /unthread.*/i // Matches unthread-specific fields + /internal.*/i, // Matches any field starting with "internal" + /company.*/i, // Matches any field starting with "company" + /webhook.*/i, // Matches webhook-specific fields + /unthread.*/i, // Matches unthread-specific fields ]); // Add dynamic sensitive field names LogEngine.addSensitiveFields([ - 'webhookSecret', - 'unthreadWebhookSecret', - 'unthreadApiKey', - 'redisPassword' + "webhookSecret", + "unthreadWebhookSecret", + "unthreadApiKey", + "redisPassword", ]); ``` @@ -308,27 +343,29 @@ LogEngine.addSensitiveFields([ ```javascript // ✅ Automatic protection - no configuration needed -LogEngine.info('Webhook authentication', { - webhookId: '123456789', // ✅ Visible - webhookSecret: 'secret123', // ❌ [REDACTED] - targetPlatform: 'telegram', // ✅ Visible - unthreadApiKey: 'key_123' // ❌ [REDACTED] +LogEngine.info("Webhook authentication", { + webhookId: "123456789", // ✅ Visible + webhookSecret: "secret123", // ❌ [REDACTED] + targetPlatform: "telegram", // ✅ Visible + unthreadApiKey: "key_123", // ❌ [REDACTED] }); // ✅ Event processing protection -LogEngine.info('Event processing', { - eventType: 'message_created', // ✅ Visible - eventId: 'evt_001', // ✅ Visible - signature: 'sha256=...', // ❌ [REDACTED] - payload: { /* large data */ } // Automatically truncated +LogEngine.info("Event processing", { + eventType: "message_created", // ✅ Visible + eventId: "evt_001", // ✅ Visible + signature: "sha256=...", // ❌ [REDACTED] + payload: { + /* large data */ + }, // Automatically truncated }); // ✅ Redis queue security -LogEngine.info('Queue publishing', { - queueName: 'unthread-events', // ✅ Visible - platform: 'unthread', // ✅ Visible - redisUrl: 'redis://localhost', // ❌ [REDACTED] - eventCount: 5 // ✅ Visible +LogEngine.info("Queue publishing", { + queueName: "unthread-events", // ✅ Visible + platform: "unthread", // ✅ Visible + redisUrl: "redis://localhost", // ❌ [REDACTED] + eventCount: 5, // ✅ Visible }); ``` @@ -370,16 +407,16 @@ LOG_TRUNCATION_TEXT="... [CONFIDENTIAL_TRUNCATED]" ```javascript // ⚠️ Use with caution - bypasses all redaction -LogEngine.debugRaw('Full webhook payload', { - password: 'visible', // ⚠️ Visible (not redacted) - apiKey: 'full-key-visible' // ⚠️ Visible (not redacted) +LogEngine.debugRaw("Full webhook payload", { + password: "visible", // ⚠️ Visible (not redacted) + apiKey: "full-key-visible", // ⚠️ Visible (not redacted) }); // Temporary redaction bypass -LogEngine.withoutRedaction().info('Debug mode', sensitiveData); +LogEngine.withoutRedaction().info("Debug mode", sensitiveData); // Test field redaction -const isRedacted = LogEngine.testFieldRedaction('webhookSecret'); // true +const isRedacted = LogEngine.testFieldRedaction("webhookSecret"); // true const currentConfig = LogEngine.getRedactionConfig(); ``` diff --git a/Dockerfile b/Dockerfile index 6b280c4..c37cde4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,8 @@ # syntax=docker/dockerfile:1 -# Use Node.js 22.22 LTS Alpine with security patches -ARG NODE_VERSION=22.22-alpine3.23 +# Use Node.js 26 LTS Alpine with security patches +ARG NODE_VERSION=26-alpine3.23 # Bun version used in build stages (must match packageManager in package.json) ARG BUN_VERSION=1.3.13 diff --git a/README.md b/README.md index 11a8078..6fa6b95 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,17 @@ A reliable, production-ready Node.js server for processing Unthread.io webhooks These outstanding organizations partner with us to support our open-source work: -|
💎 Platinum Sponsor
| -|:-------------------------------------------:| + +|
💎 Platinum Sponsor
| +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | | Unthread | -|
Unthread
Streamlined support ticketing for modern teams.
| +|
Unthread
Streamlined support ticketing for modern teams.
| + ## 🚀 Quick Start -**Requirements**: Node.js 22+, Redis, Bun +**Requirements**: Node.js 22, 24, or 26 (default runtime: Node.js 26), Redis, Bun 1.3.13 ```bash # 1. Install dependencies @@ -79,6 +81,39 @@ Server runs on `http://localhost:3000` with endpoints: - **Security-First Linting**: ESLint with comprehensive security plugins (security, no-secrets, promise handling) - **Code Quality**: Automated code quality checks with TypeScript-ESLint integration +## 🎯 Deployment Model: One Server Per Platform + +This webhook server is designed to handle **one target platform per instance**. Each server instance processes Unthread webhooks and routes events to a single platform's queue: + +- **Discord Integration**: Deploy with `TARGET_PLATFORM=discord` +- **Telegram Integration**: Deploy with `TARGET_PLATFORM=telegram` +- **WhatsApp Integration**: Deploy with `TARGET_PLATFORM=whatsapp` +- **Other Platforms**: Deploy separate instance with `TARGET_PLATFORM=` + +**Why one server per platform?** + +- Simplifies configuration and deployment +- Isolates platform-specific logic and queue handling +- Prevents cross-platform event routing errors +- Enables independent scaling per platform +- Makes monitoring and debugging clearer + +**Example Multi-Platform Setup:** + +```text +Unthread Webhooks + ↓ + ┌───┴────────────────────────┐ + ↓ ↓ +Server 1 Server 2 +TARGET_PLATFORM=discord TARGET_PLATFORM=telegram + ↓ ↓ +Redis Queue Redis Queue +unthread-discord unthread-telegram + ↓ ↓ +Discord Bot Telegram Bot +``` + ## 🚂 One-Click Deploy Deploy instantly to Railway with a single click: @@ -117,9 +152,9 @@ docker-compose down ## 🏗️ Development Container -Dev container with Node.js 22.16, Bun, and essential VS Code extensions (Copilot, ESLint, Docker, GitLens). +Recommended local toolchain is Node.js 26 and Bun 1.3.13. Node.js 22 and 24 are also supported for source/runtime compatibility. -**Quick Start:** Open in VS Code → Click "Reopen in Container" → Start coding +**Quick Start:** Install dependencies with `bun install` and start coding ## ⚙️ Configuration @@ -133,13 +168,13 @@ cp .env.example .env Required variables: -| Variable | Description | Default | Required | -|----------|-------------|---------|----------| -| `UNTHREAD_WEBHOOK_SECRET` | Your Unthread.io signing secret | - | ✅ | -| `NODE_ENV` | Environment mode | `development` | ❌ | -| `PORT` | Server port | `3000` | ❌ | -| `TARGET_PLATFORM` | Platform identifier (e.g., telegram, discord) | - | ✅ | -| `REDIS_URL` | Redis connection URL | `redis://localhost:6379` | ❌ | +| Variable | Description | Default | Required | +| ------------------------- | --------------------------------------------- | ------------------------ | -------- | +| `UNTHREAD_WEBHOOK_SECRET` | Your Unthread.io signing secret | - | ✅ | +| `NODE_ENV` | Environment mode | `development` | ❌ | +| `PORT` | Server port | `3000` | ❌ | +| `TARGET_PLATFORM` | Platform identifier (e.g., telegram, discord) | - | ✅ | +| `REDIS_URL` | Redis connection URL | `redis://localhost:6379` | ❌ | ### Getting Your Unthread Signing Secret @@ -175,7 +210,7 @@ This server features advanced file attachment correlation that: - `url_verification` - Automatic URL verification - `conversation_created` - New conversations -- `conversation_updated` - Status changes +- `conversation_updated` - Status changes - `conversation_deleted` - Conversation removal - `message_created` - New messages @@ -187,7 +222,7 @@ Events are queued with this enhanced structure: { "platform": "unthread", "targetPlatform": "telegram", - "type": "message_created", + "type": "message_created", "sourcePlatform": "dashboard", "data": { "eventId": "evt_123456789", @@ -217,7 +252,7 @@ Events are queued with this enhanced structure: **New Enhancement**: Events with file attachments now include an `attachments` metadata object providing: - `hasFiles`: Boolean indicating presence of files -- `fileCount`: Total number of attached files +- `fileCount`: Total number of attached files - `totalSize`: Combined size of all files in bytes - `types`: Array of unique MIME types (deduplicated) - `names`: Array of all file names (maintains order) @@ -335,7 +370,7 @@ curl http://localhost:3000/health ```json { - "status": "ERROR", + "status": "ERROR", "redis": "disconnected", "timestamp": "2025-06-21T12:00:00.000Z" } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..ec0a193 --- /dev/null +++ b/biome.json @@ -0,0 +1,176 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noAdjacentSpacesInRegex": "error", + "noExtraBooleanCast": "error", + "noUselessCatch": "error", + "noUselessEscapeInRegex": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInvalidBuiltinInstantiation": "error", + "noInvalidConstructorSuper": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedPrivateClassMembers": "error", + "noUnusedVariables": "error", + "useIsNan": "error", + "useValidForDirection": "error", + "useValidTypeof": "error", + "useYield": "error" + }, + "nursery": { + "useErrorCause": "error" + }, + "suspicious": { + "noAssignInExpressions": "error", + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noConstantBinaryExpressions": "error", + "noControlCharactersInRegex": "error", + "noDebugger": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateElseIf": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noIrregularWhitespace": "error", + "noMisleadingCharacterClass": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noSparseArray": "error", + "noUnassignedVariables": "error", + "noUnsafeNegation": "error", + "noUselessRegexBackrefs": "error", + "noWith": "error", + "useGetterReturn": "error" + } + }, + "includes": [ + "**", + "!node_modules/**", + "!dist/**", + "!build/**", + "!coverage/**", + "!*.min.js", + "!.yarn/**", + "!tmp/**", + "!temp/**" + ] + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "overrides": [ + { + "includes": ["**/*.ts", "**/*.js"], + "linter": { + "rules": { + "complexity": { + "useArrowFunction": "warn" + }, + "correctness": { + "noUndeclaredVariables": "warn", + "noUnresolvedImports": "warn", + "noUnusedVariables": "error" + }, + "nursery": { + "noFloatingPromises": "error", + "noImpliedEval": "error", + "noMisusedPromises": "error", + "noNestedPromises": "warn", + "useExplicitReturnType": "off", + "useExplicitType": "off" + }, + "security": { + "noGlobalEval": "error", + "noSecrets": "error" + }, + "style": { + "noNonNullAssertion": "warn", + "useConst": "error", + "useThrowOnlyError": "error" + }, + "suspicious": { + "noConsole": "off", + "noDoubleEquals": "error", + "noExplicitAny": "warn", + "noImportCycles": "warn", + "noVar": "error" + } + } + } + }, + { + "includes": ["**/*.js"], + "linter": { + "rules": { + "style": { + "noCommonJs": "off" + } + } + } + }, + { + "includes": ["*.config.js", ".*.js"], + "linter": { + "rules": { + "style": { + "noCommonJs": "off" + }, + "suspicious": { + "noConsole": "off" + } + } + } + } + ], + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock index 0fca2bf..f57b8dc 100644 --- a/bun.lock +++ b/bun.lock @@ -12,18 +12,10 @@ "redis": "^5.12.1", }, "devDependencies": { - "@eslint/js": "10.0.1", + "@biomejs/biome": "2.4.15", "@types/express": "^5.0.6", "@types/node": "^25.6.0", - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", "axios": "^1.15.1", - "eslint": "10.2.1", - "eslint-plugin-import": "2.32.0", - "eslint-plugin-n": "17.24.0", - "eslint-plugin-no-secrets": "2.3.3", - "eslint-plugin-promise": "7.3.0", - "eslint-plugin-security": "4.0.0", "nodemon": "^3.1.14", "ts-node": "^10.9.2", "typescript": "^6.0.3", @@ -31,37 +23,28 @@ }, }, "overrides": { - "flatted": "3.4.2", "picomatch": "4.0.4", }, "packages": { - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], - "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="], - "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="], - "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="], - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="], - "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], - "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -79,8 +62,6 @@ "@redis/time-series": ["@redis/time-series@5.12.1", "", { "peerDependencies": { "@redis/client": "^5.12.1" } }, "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ=="], - "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], @@ -93,20 +74,12 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], @@ -117,60 +90,20 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], - "@wgtechlabs/log-engine": ["@wgtechlabs/log-engine@2.3.1", "", {}, "sha512-qYwGef4KjcWpFvfC/e6uum/Q2ICTJbaXBZf+qCDmho1rNZ/umqyeDH3Cvk86e2Jy5TkDFPA+rOcTkmrM1rGKkw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], - "ajv": ["ajv@6.15.0", "", { "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" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], - "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], - - "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], - - "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], - - "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], - - "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], - - "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - - "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -185,8 +118,6 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -197,8 +128,6 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -209,30 +138,14 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], - - "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], - - "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], - "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -241,10 +154,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], - - "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -253,78 +162,20 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], - - "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.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", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.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", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], - - "eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], - - "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], - - "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], - - "eslint-plugin-es-x": ["eslint-plugin-es-x@7.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.1.2", "@eslint-community/regexpp": "^4.11.0", "eslint-compat-utils": "^0.5.1" }, "peerDependencies": { "eslint": ">=8" } }, "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ=="], - - "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], - - "eslint-plugin-n": ["eslint-plugin-n@17.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw=="], - - "eslint-plugin-no-secrets": ["eslint-plugin-no-secrets@2.3.3", "", { "peerDependencies": { "eslint": ">=5" } }, "sha512-sroCsCscpwQxZ/O9Ml69w5qt/2nvHhXDeyUnSqSC5dMANGxt4rQgUn7xbPvDmhbMPmUCAc2Y3SRU0cXet7kNWQ=="], - - "eslint-plugin-promise": ["eslint-plugin-promise@7.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-6uGiOR0INuujr6PEQmeSSP7GbIMJ/ebEXXiEzb/nOj68LknH5Pxzb/AbZivmr6VE6TkTE8rTjRK9zhKpK6HsRA=="], - - "eslint-plugin-security": ["eslint-plugin-security@4.0.0", "", { "dependencies": { "safe-regex": "^2.1.1" } }, "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ=="], - - "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-validator": ["express-validator@7.3.2", "", { "dependencies": { "lodash": "^4.18.1", "validator": "~13.15.23" } }, "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -335,40 +186,16 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], - - "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], - - "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -379,90 +206,22 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], - - "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], - - "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], - - "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - - "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], - - "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - - "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], - - "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], - - "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], - - "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], - - "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], - - "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], - - "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], - - "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], @@ -479,70 +238,32 @@ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - "nodemon": ["nodemon@3.1.14", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - - "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], - - "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], - - "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], - - "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -553,46 +274,18 @@ "redis": ["redis@5.12.1", "", { "dependencies": { "@redis/bloom": "5.12.1", "@redis/client": "5.12.1", "@redis/json": "5.12.1", "@redis/search": "5.12.1", "@redis/time-series": "5.12.1" } }, "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g=="], - "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - - "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], - - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - - "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], - - "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], - - "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], - - "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - - "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], - - "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -605,118 +298,38 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], - - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], - - "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "ts-declaration-location": ["ts-declaration-location@1.0.7", "", { "dependencies": { "picomatch": "^4.0.2" }, "peerDependencies": { "typescript": ">=4.0.0" } }, "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA=="], - "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], - - "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], - - "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], "validator": ["validator@13.15.35", "", {}, "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], - - "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], - - "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - - "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "eslint-compat-utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "eslint-plugin-import/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "eslint-plugin-n/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "eslint-plugin-n/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "nodemon/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index e6c64b7..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,157 +0,0 @@ -const js = require('@eslint/js'); -const tseslint = require('@typescript-eslint/eslint-plugin'); -const tsparser = require('@typescript-eslint/parser'); -const security = require('eslint-plugin-security'); -const noSecrets = require('eslint-plugin-no-secrets'); -const nodePlugin = require('eslint-plugin-n'); -const importPlugin = require('eslint-plugin-import'); -const promisePlugin = require('eslint-plugin-promise'); - -module.exports = [ - // Ignore patterns - { - ignores: [ - 'node_modules/**', - 'dist/**', - 'build/**', - 'coverage/**', - '*.min.js', - '.yarn/**', - 'tmp/**', - 'temp/**', - 'eslint.config.js', // Exclude config file itself - '**/*.test.ts', // Test files (not in tsconfig project) - '**/*.spec.ts', // Spec files (not in tsconfig project) - 'vitest.config.ts', // Vitest config (not in tsconfig project) - ], - }, - - // Base recommended configuration for all files - js.configs.recommended, - - // TypeScript and JavaScript files configuration - { - files: ['**/*.ts', '**/*.js'], - languageOptions: { - parser: tsparser, - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - project: './tsconfig.json', - }, - globals: { - console: 'readonly', - process: 'readonly', - Buffer: 'readonly', - __dirname: 'readonly', - __filename: 'readonly', - module: 'readonly', - require: 'readonly', - exports: 'readonly', - setTimeout: 'readonly', - setInterval: 'readonly', - clearTimeout: 'readonly', - clearInterval: 'readonly', - }, - }, - plugins: { - '@typescript-eslint': tseslint, - 'security': security, - 'no-secrets': noSecrets, - 'n': nodePlugin, - 'import': importPlugin, - 'promise': promisePlugin, - }, - rules: { - // TypeScript recommended rules - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], - '@typescript-eslint/no-non-null-assertion': 'warn', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/no-misused-promises': 'error', - - // Security rules - Critical - 'security/detect-object-injection': 'warn', // Set to warn to avoid false positives - 'security/detect-non-literal-regexp': 'error', - 'security/detect-unsafe-regex': 'error', - 'security/detect-buffer-noassert': 'error', - 'security/detect-child-process': 'warn', - 'security/detect-disable-mustache-escape': 'error', - 'security/detect-eval-with-expression': 'error', - 'security/detect-no-csrf-before-method-override': 'error', - 'security/detect-non-literal-fs-filename': 'warn', - 'security/detect-non-literal-require': 'warn', - 'security/detect-possible-timing-attacks': 'warn', - 'security/detect-pseudoRandomBytes': 'error', - - // No secrets plugin - 'no-secrets/no-secrets': ['error', { - tolerance: 4.5, - additionalRegexes: { - 'Unthread Secret': 'unthread[_-]?secret', - 'Webhook Secret': 'webhook[_-]?secret', - }, - }], - - // Node.js best practices - 'n/no-unsupported-features/es-syntax': 'off', // TypeScript handles this - 'n/no-missing-import': 'off', // TypeScript handles this - 'n/no-unpublished-import': 'off', // TypeScript handles this - 'n/no-deprecated-api': 'error', - 'n/no-process-exit': 'warn', - - // Import/Export validation - 'import/no-unresolved': 'off', // TypeScript handles this - 'import/named': 'off', // TypeScript handles this - 'import/default': 'off', // TypeScript handles this - 'import/no-duplicates': 'error', - 'import/no-cycle': 'warn', - 'import/no-self-import': 'error', - - // Promise handling best practices - 'promise/always-return': 'warn', - 'promise/catch-or-return': 'error', - 'promise/no-return-wrap': 'error', - 'promise/param-names': 'error', - 'promise/no-nesting': 'warn', - 'promise/no-promise-in-callback': 'warn', - 'promise/no-callback-in-promise': 'warn', - - // General best practices - 'no-console': 'off', // We use LogEngine but console might be needed - 'no-unused-vars': 'off', // Handled by TypeScript rule - 'no-undef': 'off', // TypeScript handles this - 'eqeqeq': ['error', 'always'], - 'no-var': 'error', - 'prefer-const': 'error', - 'prefer-arrow-callback': 'warn', - 'no-throw-literal': 'error', - 'no-eval': 'error', - 'no-implied-eval': 'error', - 'no-new-func': 'error', - 'no-return-await': 'warn', - }, - }, - - // JavaScript-specific configuration (less strict) - { - files: ['**/*.js'], - rules: { - '@typescript-eslint/no-var-requires': 'off', - }, - }, - - // Configuration files (less strict) - { - files: ['*.config.js', '.*.js'], - rules: { - '@typescript-eslint/no-var-requires': 'off', - 'no-console': 'off', - }, - }, -]; diff --git a/package.json b/package.json index 06a4468..9760fac 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "main": "dist/app.js", "engines": { - "node": ">=22.0.0", + "node": ">=22.0.0 <27.0.0", "bun": "1.3.13" }, "scripts": { @@ -29,10 +29,9 @@ "dev": "nodemon --exec ts-node src/app.ts", "clean": "rm -rf dist", "type-check": "tsc --noEmit", - "lint": "eslint . --ext .ts,.js", - "lint:fix": "eslint . --ext .ts,.js --fix", - "lint:security": "eslint . --ext .ts,.js", - "lint:ci": "eslint . --ext .ts,.js --max-warnings 1", + "lint": "biome check .", + "lint:fix": "biome check . --write", + "format": "biome format . --write", "test": "bun test", "test:watch": "bun test --watch", "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov" @@ -45,24 +44,15 @@ "redis": "^5.12.1" }, "devDependencies": { - "@eslint/js": "10.0.1", + "@biomejs/biome": "2.4.15", "@types/express": "^5.0.6", "@types/node": "^25.6.0", - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", "axios": "^1.15.1", - "eslint": "10.2.1", - "eslint-plugin-import": "2.32.0", - "eslint-plugin-n": "17.24.0", - "eslint-plugin-no-secrets": "2.3.3", - "eslint-plugin-promise": "7.3.0", - "eslint-plugin-security": "4.0.0", "nodemon": "^3.1.14", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, "overrides": { - "flatted": "3.4.2", "picomatch": "4.0.4" }, "packageManager": "bun@1.3.13" diff --git a/src/app.ts b/src/app.ts index 47047b8..89d7a5f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,23 +1,23 @@ import 'dotenv/config'; -// Initialize environment validation first (will crash server if validation fails) -import { config } from './config/env'; +import { LogEngine, LogMode } from '@wgtechlabs/log-engine'; import express, { Request, Response } from 'express'; -import { LogEngine, LogMode } from '@wgtechlabs/log-engine'; +// Initialize environment validation first (will crash server if validation fails) +import { config } from './config/env'; import { WebhookController } from './controllers/webhookController'; import { verifySignature } from './middleware/auth'; import { validateEvent } from './middleware/validation'; -import { WebhookRequest } from './types'; import { RedisService } from './services/redisService'; +import { WebhookRequest } from './types'; // Configure LogEngine with no timestamps (emoji + level + message only) -LogEngine.configure({ +LogEngine.configure({ mode: config.nodeEnv === 'production' ? LogMode.INFO : LogMode.DEBUG, format: { includeIsoTimestamp: false, - includeLocalTime: false - } + includeLocalTime: false, + }, }); const app = express(); @@ -34,105 +34,110 @@ app.use( verify: (req: Request, _res: Response, buf: Buffer) => { // Add rawBody property for webhook signature verification (req as WebhookRequest).rawBody = buf.toString(); - } - }) + }, + }), ); // Health check endpoint with Redis and background processor check app.get('/health', async (req: Request, res: Response) => { - try { - const isConnected = healthRedisService.isConnected(); - const backgroundStatus = WebhookController.getBackgroundProcessorStatus(); - - if (isConnected && backgroundStatus.initialized) { - res.status(200).json({ - status: 'OK', - redis: 'connected', - backgroundProcessor: 'initialized', - timestamp: new Date().toISOString() - }); - } else { - res.status(503).json({ - status: 'ERROR', - redis: isConnected ? 'connected' : 'disconnected', - backgroundProcessor: backgroundStatus.initialized ? 'initialized' : 'not_initialized', - timestamp: new Date().toISOString() - }); - } - } catch { - res.status(503).json({ - status: 'ERROR', - redis: 'error', - timestamp: new Date().toISOString() - }); + try { + const isConnected = healthRedisService.isConnected(); + const backgroundStatus = WebhookController.getBackgroundProcessorStatus(); + + if (isConnected && backgroundStatus.initialized) { + res.status(200).json({ + status: 'OK', + redis: 'connected', + backgroundProcessor: 'initialized', + timestamp: new Date().toISOString(), + }); + } else { + res.status(503).json({ + status: 'ERROR', + redis: isConnected ? 'connected' : 'disconnected', + backgroundProcessor: backgroundStatus.initialized + ? 'initialized' + : 'not_initialized', + timestamp: new Date().toISOString(), + }); } + } catch { + res.status(503).json({ + status: 'ERROR', + redis: 'error', + timestamp: new Date().toISOString(), + }); + } }); // Main webhook endpoint with proper middleware chain // TODO: add rate limiting -app.post('/unthread-webhook', - verifySignature, - ...validateEvent, - (req: Request, res: Response) => { - void webhookController.handleWebhook(req as WebhookRequest, res); - } +app.post( + '/unthread-webhook', + verifySignature, + ...validateEvent, + (req: Request, res: Response) => { + void webhookController.handleWebhook(req as WebhookRequest, res); + }, ); // Initialize Redis connection on startup async function startServer() { - try { - LogEngine.log('Starting Unthread webhook server...'); - - LogEngine.log('Attempting to connect to Redis...'); - await startupRedisService.connect(); - - const server = app.listen(port, () => { - // Production-friendly startup message - always visible - LogEngine.log(`Unthread webhook server started on port ${port}`); - - // Debug-only detailed information - LogEngine.debug(`Endpoints: /health | /unthread-webhook`); - LogEngine.debug(`Mode: ${config.nodeEnv} | Platform: ${config.targetPlatform}`); - }); - - const shutdown = (signal: string) => { - if (isShuttingDown) { - return; - } - isShuttingDown = true; - LogEngine.log(`Received ${signal}, starting graceful shutdown`); - - const forceExit = setTimeout(() => { - LogEngine.error('Graceful shutdown timeout reached, forcing exit'); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - }, 10000); - forceExit.unref(); - - server.close(() => { - Promise.resolve() - .then(async () => { - webhookController.destroy?.(); - await startupRedisService.close(); - LogEngine.log('Graceful shutdown completed'); - // eslint-disable-next-line n/no-process-exit - process.exit(0); - }) - .catch((error) => { - LogEngine.error(`Error during shutdown: ${error}`); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - }); - }); - }; - - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - } catch (error) { - LogEngine.error(`Failed to start server: ${error}`); + try { + LogEngine.log('Starting Unthread webhook server...'); + + LogEngine.log('Attempting to connect to Redis...'); + await startupRedisService.connect(); + + const server = app.listen(port, () => { + // Production-friendly startup message - always visible + LogEngine.log(`Unthread webhook server started on port ${port}`); + + // Debug-only detailed information + LogEngine.debug(`Endpoints: /health | /unthread-webhook`); + LogEngine.debug( + `Mode: ${config.nodeEnv} | Platform: ${config.targetPlatform}`, + ); + }); + + const shutdown = (signal: string) => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + LogEngine.log(`Received ${signal}, starting graceful shutdown`); + + const forceExit = setTimeout(() => { + LogEngine.error('Graceful shutdown timeout reached, forcing exit'); // eslint-disable-next-line n/no-process-exit process.exit(1); - } + }, 10000); + forceExit.unref(); + + server.close(() => { + Promise.resolve() + .then(async () => { + webhookController.destroy?.(); + await startupRedisService.close(); + LogEngine.log('Graceful shutdown completed'); + // eslint-disable-next-line n/no-process-exit + process.exit(0); + }) + .catch((error) => { + LogEngine.error(`Error during shutdown: ${error}`); + // eslint-disable-next-line n/no-process-exit + process.exit(1); + }); + }); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + } catch (error) { + LogEngine.error(`Failed to start server: ${error}`); + // eslint-disable-next-line n/no-process-exit + process.exit(1); + } } void startServer(); diff --git a/src/config/env.ts b/src/config/env.ts index 6090177..503d344 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -6,43 +6,49 @@ // Validate TARGET_PLATFORM to prevent conflicts with reserved values // Returns the platform string in lowercase for canonical format throughout codebase function validateTargetPlatform(platform: string | undefined): string { - // Check if TARGET_PLATFORM is provided - if (!platform || platform.trim() === '') { - throw new Error( - 'TARGET_PLATFORM environment variable is required. ' + - 'Please set it to your target platform (e.g., discord, telegram, whatsapp, messenger, etc.)' - ); - } + // Check if TARGET_PLATFORM is provided + if (!platform || platform.trim() === '') { + throw new Error( + 'TARGET_PLATFORM environment variable is required. ' + + 'Please set it to your target platform (e.g., discord, telegram, whatsapp, messenger, etc.)', + ); + } - const cleanPlatform = platform.trim().toLowerCase(); // Convert to lowercase for canonical format - const reservedPlatforms = ['dashboard', 'unknown', 'buffered']; - - if (reservedPlatforms.includes(cleanPlatform)) { - throw new Error( - `TARGET_PLATFORM cannot be "${platform.trim()}" as it's a reserved value. ` + - `Reserved platform values: ${reservedPlatforms.join(', ')}. ` + - `Use values like: discord, telegram, whatsapp, messenger, etc.` - ); - } - - return cleanPlatform; // Always returns lowercase + const cleanPlatform = platform.trim().toLowerCase(); // Convert to lowercase for canonical format + const reservedPlatforms = ['dashboard', 'unknown', 'buffered']; + + if (reservedPlatforms.includes(cleanPlatform)) { + throw new Error( + `TARGET_PLATFORM cannot be "${platform.trim()}" as it's a reserved value. ` + + `Reserved platform values: ${reservedPlatforms.join(', ')}. ` + + `Use values like: discord, telegram, whatsapp, messenger, etc.`, + ); + } + + return cleanPlatform; // Always returns lowercase } const targetPlatform = process.env.TARGET_PLATFORM; -const parsedWebhookMaxSkewSeconds = Number.parseInt(process.env.WEBHOOK_MAX_SKEW_SECONDS || '300', 10); -const webhookMaxSkewSeconds = Number.isFinite(parsedWebhookMaxSkewSeconds) && parsedWebhookMaxSkewSeconds > 0 +const parsedWebhookMaxSkewSeconds = Number.parseInt( + process.env.WEBHOOK_MAX_SKEW_SECONDS || '300', + 10, +); +const webhookMaxSkewSeconds = + Number.isFinite(parsedWebhookMaxSkewSeconds) && + parsedWebhookMaxSkewSeconds > 0 ? parsedWebhookMaxSkewSeconds : 300; export const config = { - nodeEnv: process.env.NODE_ENV || 'development', - port: parseInt(process.env.PORT || '3000', 10), - targetPlatform: validateTargetPlatform(targetPlatform), // Always lowercase for canonical format - redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', - unthreadWebhookSecret: process.env.UNTHREAD_WEBHOOK_SECRET || '', - webhookMaxSkewSeconds, - webhookSkewEnforce: (process.env.WEBHOOK_SKEW_ENFORCE || 'true').toLowerCase() !== 'false', - unthreadQueueName: 'unthread-events' // Simple hardcoded queue name + nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3000', 10), + targetPlatform: validateTargetPlatform(targetPlatform), // Always lowercase for canonical format + redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', + unthreadWebhookSecret: process.env.UNTHREAD_WEBHOOK_SECRET || '', + webhookMaxSkewSeconds, + webhookSkewEnforce: + (process.env.WEBHOOK_SKEW_ENFORCE || 'true').toLowerCase() !== 'false', + unthreadQueueName: 'unthread-events', // Simple hardcoded queue name }; // Also export as default diff --git a/src/config/redis.ts b/src/config/redis.ts index 9c57ff4..f6e1e15 100644 --- a/src/config/redis.ts +++ b/src/config/redis.ts @@ -1,72 +1,82 @@ -import { createClient } from 'redis'; -import type { RedisClientType } from 'redis'; import { LogEngine } from '@wgtechlabs/log-engine'; +import type { RedisClientType } from 'redis'; +import { createClient } from 'redis'; // Simple Redis configuration function parseRedisConfig() { - const redisUrl = process.env.REDIS_URL; - - if (!redisUrl) { - throw new Error('REDIS_URL environment variable is required. Please provide a valid Redis URL (e.g., redis://username:password@host:port)'); - } - - try { - const url = new URL(redisUrl); - const parsedPort = Number.parseInt(url.port, 10); - const config: { host: string; port: number; url: string; password?: string } = { - host: url.hostname, - port: Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : 6379, - url: redisUrl - }; - - if (url.password) { - config.password = url.password; - } - - return config; - } catch (error) { - throw new Error( - `Invalid REDIS_URL format: ${(error as Error).message}. Expected format: redis://username:password@host:port`, - { cause: error } - ); + const redisUrl = process.env.REDIS_URL; + + if (!redisUrl) { + throw new Error( + 'REDIS_URL environment variable is required. Please provide a valid Redis URL (e.g., redis://username:password@host:port)', + ); + } + + try { + const url = new URL(redisUrl); + const parsedPort = Number.parseInt(url.port, 10); + const config: { + host: string; + port: number; + url: string; + password?: string; + } = { + host: url.hostname, + port: + Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 + ? parsedPort + : 6379, + url: redisUrl, + }; + + if (url.password) { + config.password = url.password; } + + return config; + } catch (error) { + throw new Error( + `Invalid REDIS_URL format: ${(error as Error).message}. Expected format: redis://username:password@host:port`, + { cause: error }, + ); + } } const redisConfig = parseRedisConfig(); // Event tracking configuration - hardcoded values export const redisEventConfig = { - // Event tracking configuration - eventTtl: 259200, // 3 days (72 hours * 60 minutes * 60 seconds) - keyPrefix: 'unthread:eventid:', - fingerprintPrefix: 'unthread:fp:', + // Event tracking configuration + eventTtl: 259200, // 3 days (72 hours * 60 minutes * 60 seconds) + keyPrefix: 'unthread:eventid:', + fingerprintPrefix: 'unthread:fp:', }; // Create Redis client for v4.x - use URL string directly with timeout -const client: RedisClientType = createClient({ - url: redisConfig.url, - socket: { - connectTimeout: 5000 // 5 second timeout - } +const client: RedisClientType = createClient({ + url: redisConfig.url, + socket: { + connectTimeout: 5000, // 5 second timeout + }, }); -client.on('error', (err?: Error) => { - LogEngine.error(`Redis connection error: ${err}`); +client.on('error', (err: Error) => { + LogEngine.error('Redis connection error', { + message: err.message, + stack: err.stack, + }); }); client.on('ready', () => { - LogEngine.log('Redis connection established'); + LogEngine.log('Redis connection established'); }); client.on('connect', () => { - LogEngine.log('Redis client connected'); + LogEngine.log('Redis client connected'); }); client.on('reconnecting', () => { - LogEngine.log('Redis client reconnecting...'); + LogEngine.log('Redis client reconnecting...'); }); -export { - client, - redisConfig, -}; +export { client, redisConfig }; diff --git a/src/controllers/webhookController.test.ts b/src/controllers/webhookController.test.ts index 401384f..21a01e8 100644 --- a/src/controllers/webhookController.test.ts +++ b/src/controllers/webhookController.test.ts @@ -18,8 +18,8 @@ const event: UnthreadWebhookEvent = { webhookTimestamp: Date.now(), data: { id: 'msg-1', - conversationId: 'conv-1' - } + conversationId: 'conv-1', + }, }; const createRes = () => { @@ -33,10 +33,10 @@ const createRes = () => { json(payload: any) { state.body = payload; return payload; - } + }, }; - } - } + }, + }, }; }; @@ -49,7 +49,7 @@ describe('WebhookController', () => { webhookService = { validateEvent: mock(() => ({ isValid: true })), processEvent: mock(async () => undefined), - destroy: mock(() => undefined) + destroy: mock(() => undefined), }; (controller as any).webhookService = webhookService; }); @@ -64,7 +64,10 @@ describe('WebhookController', () => { }); it('returns 400 on validation failure', async () => { - webhookService.validateEvent = mock(() => ({ isValid: false, errors: ['Missing required field: eventId'] })); + webhookService.validateEvent = mock(() => ({ + isValid: false, + errors: ['Missing required field: eventId'], + })); const { state, res } = createRes(); await controller.handleWebhook({ body: event } as any, res as any); expect(state.status).toBe(400); @@ -73,7 +76,10 @@ describe('WebhookController', () => { it('returns 200 for url_verification without processing', async () => { const { state, res } = createRes(); - await controller.handleWebhook({ body: { ...event, event: 'url_verification' } } as any, res as any); + await controller.handleWebhook( + { body: { ...event, event: 'url_verification' } } as any, + res as any, + ); expect(state.status).toBe(200); expect(state.body.message).toBe('URL verified'); expect(webhookService.processEvent).not.toHaveBeenCalled(); @@ -98,6 +104,8 @@ describe('WebhookController', () => { const instance = WebhookController.initializeBackgroundProcessor(); expect(instance).toBeDefined(); expect(WebhookController.getBackgroundProcessor()).toBe(instance); - expect(WebhookController.getBackgroundProcessorStatus().initialized).toBe(true); + expect(WebhookController.getBackgroundProcessorStatus().initialized).toBe( + true, + ); }); }); diff --git a/src/controllers/webhookController.ts b/src/controllers/webhookController.ts index 2949ede..f9ca060 100644 --- a/src/controllers/webhookController.ts +++ b/src/controllers/webhookController.ts @@ -1,126 +1,135 @@ -import { Response } from 'express'; -import { randomUUID } from 'crypto'; import { LogEngine } from '@wgtechlabs/log-engine'; +import { randomUUID } from 'crypto'; +import { Response } from 'express'; import { WebhookService } from '../services/webhookService'; -import { WebhookRequest, WebhookResponse, ErrorResponse } from '../types'; +import { ErrorResponse, WebhookRequest, WebhookResponse } from '../types'; export class WebhookController { - private webhookService: WebhookService; - private static sharedProcessor: WebhookController | null = null; + private webhookService: WebhookService; + private static sharedProcessor: WebhookController | null = null; - constructor() { - this.webhookService = new WebhookService(); - } + constructor() { + this.webhookService = new WebhookService(); + } - /** - * Initialize the shared webhook processor singleton. - * - * Historically this controller queued events for asynchronous background - * processing. That pattern was removed because it caused the same logical - * event to be enqueued multiple times in Redis when Unthread retried with - * a new eventId before the in-flight async work finished. The controller - * now processes events inline (see {@link handleWebhook}); this singleton - * exists to share a single WebhookService (and its Redis client) across - * requests and to allow graceful shutdown via {@link destroy}. - * - * The legacy method/property names are kept for backwards compatibility - * with `app.ts` and existing tests. - */ - static initializeBackgroundProcessor(): WebhookController { - if (!WebhookController.sharedProcessor) { - WebhookController.sharedProcessor = new WebhookController(); - LogEngine.log('Shared webhook processor initialized'); - } - return WebhookController.sharedProcessor; + /** + * Initialize the shared webhook processor singleton. + * + * Historically this controller queued events for asynchronous background + * processing. That pattern was removed because it caused the same logical + * event to be enqueued multiple times in Redis when Unthread retried with + * a new eventId before the in-flight async work finished. The controller + * now processes events inline (see {@link handleWebhook}); this singleton + * exists to share a single WebhookService (and its Redis client) across + * requests and to allow graceful shutdown via {@link destroy}. + * + * The legacy method/property names are kept for backwards compatibility + * with `app.ts` and existing tests. + */ + static initializeBackgroundProcessor(): WebhookController { + if (!WebhookController.sharedProcessor) { + WebhookController.sharedProcessor = new WebhookController(); + LogEngine.log('Shared webhook processor initialized'); } + return WebhookController.sharedProcessor; + } - static getBackgroundProcessor(): WebhookController | null { - return WebhookController.sharedProcessor; - } + static getBackgroundProcessor(): WebhookController | null { + return WebhookController.sharedProcessor; + } - /** - * Handle a webhook request. - * - * Processing is performed inline: the request is validated, then - * {@link WebhookService.processEvent} is awaited (signature/skew checks - * happen earlier in the auth middleware). Only after the event has been - * fully processed (and, when appropriate, enqueued in Redis) do we - * respond with 200. This intentionally couples request latency to the - * Redis enqueue so that Unthread retries do not race against in-flight - * background work and produce duplicate queue entries. - */ - async handleWebhook(req: WebhookRequest, res: Response): Promise { - const startTime = Date.now(); - - try { - const { event, eventId } = req.body; - - // Log raw incoming webhook data - LogEngine.debug(`RAW WEBHOOK RECEIVED:`, { - eventId, - completeRawData: req.body - }); - - // Handle URL verification event (required by Unthread) - if (event === 'url_verification') { - LogEngine.debug('URL verification event processed'); - return res.status(200).json({ message: 'URL verified' }); - } - - // Quick validation only - no heavy processing - const validationResult = this.webhookService.validateEvent(req.body); - if (!validationResult.isValid) { - LogEngine.error(`Event validation failed:`, validationResult.errors); - return res.status(400).json({ - error: 'Invalid event structure', - details: validationResult.errors - }); - } - - // Generate request ID for tracking - const requestId = `req_${randomUUID()}`; - - await this.webhookService.processEvent(req.body); - - const responseTime = Date.now() - startTime; - const response = res.status(200).json({ - message: 'Event processed', - eventId, - requestId, - responseTime: `${responseTime}ms`, - timestamp: new Date().toISOString() - }); - - LogEngine.debug(`Webhook response sent after inline processing`, { - eventId, - requestId, - responseTime: `${responseTime}ms` - }); - - return response; - - } catch (error) { - const responseTime = Date.now() - startTime; - LogEngine.error(`Error handling webhook: ${error}`); - return res.status(500).json({ - error: 'Internal server error', - responseTime: `${responseTime}ms`, - timestamp: new Date().toISOString() - }); - } - } + /** + * Handle a webhook request. + * + * Processing is performed inline: the request is validated, then + * {@link WebhookService.processEvent} is awaited (signature/skew checks + * happen earlier in the auth middleware). Only after the event has been + * fully processed (and, when appropriate, enqueued in Redis) do we + * respond with 200. This intentionally couples request latency to the + * Redis enqueue so that Unthread retries do not race against in-flight + * background work and produce duplicate queue entries. + */ + async handleWebhook( + req: WebhookRequest, + res: Response, + ): Promise { + const startTime = Date.now(); - /** - * Get shared webhook processor health status. - */ - static getBackgroundProcessorStatus(): { initialized: boolean; timestamp: string } { - return { - initialized: WebhookController.sharedProcessor !== null, - timestamp: new Date().toISOString() - }; - } + try { + const { event, eventId } = req.body; + + // Log raw incoming webhook data + LogEngine.debug(`RAW WEBHOOK RECEIVED:`, { + eventId, + completeRawData: req.body, + }); - destroy(): void { - this.webhookService.destroy(); + // Handle URL verification event (required by Unthread) + if (event === 'url_verification') { + LogEngine.debug('URL verification event processed'); + return res.status(200).json({ message: 'URL verified' }); + } + + // Quick validation only - no heavy processing + const validationResult = this.webhookService.validateEvent(req.body); + if (!validationResult.isValid) { + LogEngine.error(`Event validation failed:`, validationResult.errors); + return res.status(400).json({ + error: 'Invalid event structure', + details: validationResult.errors, + }); + } + + // Generate request ID for tracking + const requestId = `req_${randomUUID()}`; + + await this.webhookService.processEvent(req.body); + + const responseTime = Date.now() - startTime; + const response = res.status(200).json({ + message: 'Event processed', + eventId, + requestId, + responseTime: `${responseTime}ms`, + timestamp: new Date().toISOString(), + }); + + LogEngine.debug(`Webhook response sent after inline processing`, { + eventId, + requestId, + responseTime: `${responseTime}ms`, + }); + + return response; + } catch (error) { + const responseTime = Date.now() - startTime; + LogEngine.error('Error handling webhook', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + responseTime: `${responseTime}ms`, + }); + return res.status(500).json({ + error: 'Internal server error', + responseTime: `${responseTime}ms`, + timestamp: new Date().toISOString(), + }); } + } + + /** + * Get shared webhook processor health status. + */ + static getBackgroundProcessorStatus(): { + initialized: boolean; + timestamp: string; + } { + return { + initialized: WebhookController.sharedProcessor !== null, + timestamp: new Date().toISOString(), + }; + } + + destroy(): void { + this.webhookService.destroy(); + } } diff --git a/src/middleware/auth.test.ts b/src/middleware/auth.test.ts index 6fdb5ac..27efd88 100644 --- a/src/middleware/auth.test.ts +++ b/src/middleware/auth.test.ts @@ -1,6 +1,14 @@ -import { createHmac } from 'crypto'; -import { beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { + beforeAll, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test'; import { LogEngine } from '@wgtechlabs/log-engine'; +import { createHmac } from 'crypto'; process.env.TARGET_PLATFORM = 'whatsapp'; process.env.REDIS_URL = 'redis://localhost:6379'; @@ -27,14 +35,17 @@ const createRes = () => { json: (body: unknown) => { state.body = body; return body; - } + }, }; - } - } + }, + }, }; }; -const sign = (rawBody: string) => createHmac('sha256', config.unthreadWebhookSecret).update(rawBody).digest('hex'); +const sign = (rawBody: string) => + createHmac('sha256', config.unthreadWebhookSecret) + .update(rawBody) + .digest('hex'); describe('verifySignature middleware', () => { let warnSpy: ReturnType; @@ -60,7 +71,11 @@ describe('verifySignature middleware', () => { it('rejects missing raw body', () => { const { state, res } = createRes(); const next = mock(() => undefined); - verifySignature({ headers: { 'x-unthread-signature': '00' }, body: {} } as any, res as any, next as any); + verifySignature( + { headers: { 'x-unthread-signature': '00' }, body: {} } as any, + res as any, + next as any, + ); expect(state.status).toBe(400); expect((state.body as any).error).toBe('Missing request body'); expect(next).not.toHaveBeenCalled(); @@ -70,9 +85,13 @@ describe('verifySignature middleware', () => { const { state, res } = createRes(); const next = mock(() => undefined); verifySignature( - { headers: { 'x-unthread-signature': 'zzzz' }, body: { webhookTimestamp: Date.now() }, rawBody: '{"ok":true}' } as any, + { + headers: { 'x-unthread-signature': 'zzzz' }, + body: { webhookTimestamp: Date.now() }, + rawBody: '{"ok":true}', + } as any, res as any, - next as any + next as any, ); expect(state.status).toBe(403); expect((state.body as any).error).toBe('Invalid signature'); @@ -87,24 +106,24 @@ describe('verifySignature middleware', () => { { headers: { 'x-unthread-signature': sign(rawBody) }, body: { eventId: 'evt-1', webhookTimestamp: Date.now() }, - rawBody + rawBody, } as any, res as any, - next as any + next as any, ); expect(state.status).toBeUndefined(); expect(next).toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalledWith( 'Webhook rejected - stale timestamp', - expect.anything() + expect.anything(), ); expect(warnSpy).not.toHaveBeenCalledWith( 'Webhook skew exceeds window (observe-only, not rejected)', - expect.anything() + expect.anything(), ); expect(debugSpy).toHaveBeenCalledWith( 'Webhook timestamp within skew window', - expect.objectContaining({ eventId: 'evt-1', maxSkewSeconds: 300 }) + expect.objectContaining({ eventId: 'evt-1', maxSkewSeconds: 300 }), ); }); @@ -116,16 +135,20 @@ describe('verifySignature middleware', () => { { headers: { 'x-unthread-signature': sign(rawBody) }, body: { eventId: 'evt-2', webhookTimestamp: Date.now() - 1_000_000 }, - rawBody + rawBody, } as any, res as any, - next as any + next as any, ); expect(state.status).toBe(403); expect((state.body as any).error).toBe('Stale webhook timestamp'); expect(warnSpy).toHaveBeenCalledWith( 'Webhook rejected - stale timestamp', - expect.objectContaining({ eventId: 'evt-2', enforce: true, maxSkewSeconds: 300 }) + expect.objectContaining({ + eventId: 'evt-2', + enforce: true, + maxSkewSeconds: 300, + }), ); expect(next).not.toHaveBeenCalled(); }); @@ -139,15 +162,19 @@ describe('verifySignature middleware', () => { { headers: { 'x-unthread-signature': sign(rawBody) }, body: { eventId: 'evt-3', webhookTimestamp: Date.now() - 1_000_000 }, - rawBody + rawBody, } as any, res as any, - next as any + next as any, ); expect(state.status).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( 'Webhook skew exceeds window (observe-only, not rejected)', - expect.objectContaining({ eventId: 'evt-3', enforce: false, maxSkewSeconds: 300 }) + expect.objectContaining({ + eventId: 'evt-3', + enforce: false, + maxSkewSeconds: 300, + }), ); expect(next).toHaveBeenCalled(); }); @@ -160,10 +187,10 @@ describe('verifySignature middleware', () => { { headers: { 'x-unthread-signature': sign(rawBody) }, body: { eventId: 'evt-4' }, - rawBody + rawBody, } as any, res as any, - next as any + next as any, ); expect(state.status).toBe(403); expect((state.body as any).error).toBe('Stale webhook timestamp'); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 0d8028a..fc2710a 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,114 +1,122 @@ -import * as crypto from 'crypto'; -import { Request, Response, NextFunction } from 'express'; import { LogEngine } from '@wgtechlabs/log-engine'; +import * as crypto from 'crypto'; +import { NextFunction, Request, Response } from 'express'; import { config } from '../config/env'; import { ErrorResponse, WebhookRequest } from '../types'; const parseHexBuffer = (value: string): Buffer => { - if (!/^[a-fA-F0-9]+$/.test(value) || value.length % 2 !== 0) { - throw new Error('Invalid hex signature'); - } - return Buffer.from(value, 'hex'); + if (!/^[a-fA-F0-9]+$/.test(value) || value.length % 2 !== 0) { + throw new Error('Invalid hex signature'); + } + return Buffer.from(value, 'hex'); }; -export const verifySignature = (req: Request, res: Response, next: NextFunction): void => { - // Get the signing secret from validated environment config - const SIGNING_SECRET = config.unthreadWebhookSecret; +export const verifySignature = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + // Get the signing secret from validated environment config + const SIGNING_SECRET = config.unthreadWebhookSecret; - // Get the signature from the x-unthread-signature header - const signature = req.headers['x-unthread-signature'] as string; - - if (!signature) { - res.status(403).json({ - error: 'Missing signature header', - timestamp: new Date().toISOString() - }); - return; - } + // Get the signature from the x-unthread-signature header + const signature = req.headers['x-unthread-signature'] as string; - // Use raw body for signature verification as recommended by Unthread - // TypeScript knows rawBody exists because we set it in the express.json verify function - const rawBody = (req as WebhookRequest).rawBody; - - if (!rawBody) { - res.status(400).json({ - error: 'Missing request body', - timestamp: new Date().toISOString() - }); - return; - } + if (!signature) { + res.status(403).json({ + error: 'Missing signature header', + timestamp: new Date().toISOString(), + }); + return; + } + + // Use raw body for signature verification as recommended by Unthread + // TypeScript knows rawBody exists because we set it in the express.json verify function + const rawBody = (req as WebhookRequest).rawBody; - // Create HMAC-SHA256 hash - const hmac = crypto.createHmac('sha256', SIGNING_SECRET) - .update(rawBody) - .digest('hex'); + if (!rawBody) { + res.status(400).json({ + error: 'Missing request body', + timestamp: new Date().toISOString(), + }); + return; + } - try { - const expectedBuffer = parseHexBuffer(hmac); - const providedBuffer = parseHexBuffer(signature); + // Create HMAC-SHA256 hash + const hmac = crypto + .createHmac('sha256', SIGNING_SECRET) + .update(rawBody) + .digest('hex'); - if (expectedBuffer.length !== providedBuffer.length) { - res.status(403).json({ - error: 'Invalid signature', - timestamp: new Date().toISOString() - }); - return; - } + try { + const expectedBuffer = parseHexBuffer(hmac); + const providedBuffer = parseHexBuffer(signature); + + if (expectedBuffer.length !== providedBuffer.length) { + res.status(403).json({ + error: 'Invalid signature', + timestamp: new Date().toISOString(), + }); + return; + } - if (!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) { - res.status(403).json({ - error: 'Invalid signature', - timestamp: new Date().toISOString() - }); - return; - } - } catch { - res.status(403).json({ - error: 'Invalid signature', - timestamp: new Date().toISOString() - }); - return; + if (!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) { + res.status(403).json({ + error: 'Invalid signature', + timestamp: new Date().toISOString(), + }); + return; } + } catch { + res.status(403).json({ + error: 'Invalid signature', + timestamp: new Date().toISOString(), + }); + return; + } - const rawWebhookTimestamp = req.body?.webhookTimestamp; - const webhookTimestamp = Number(rawWebhookTimestamp); - const now = Date.now(); - const maxSkewMs = config.webhookMaxSkewSeconds * 1000; - const hasValidTimestamp = Number.isFinite(webhookTimestamp); - const skewMs = hasValidTimestamp ? Math.abs(now - webhookTimestamp) : maxSkewMs + 1000; - const skewSeconds = Math.round(skewMs / 1000); + const rawWebhookTimestamp = req.body?.webhookTimestamp; + const now = Date.now(); + const maxSkewMs = config.webhookMaxSkewSeconds * 1000; + const hasValidTimestamp = + rawWebhookTimestamp != null && Number.isFinite(Number(rawWebhookTimestamp)); + const webhookTimestamp = Number(rawWebhookTimestamp); + const skewMs = hasValidTimestamp + ? Math.abs(now - webhookTimestamp) + : maxSkewMs + 1000; + const skewSeconds = Math.round(skewMs / 1000); - if (skewMs > maxSkewMs) { - if (config.webhookSkewEnforce) { - LogEngine.warn('Webhook rejected - stale timestamp', { - eventId: req.body?.eventId, - skewSeconds, - maxSkewSeconds: config.webhookMaxSkewSeconds, - enforce: true, - webhookTimestamp: rawWebhookTimestamp, - serverTime: now - }); - res.status(403).json({ - error: 'Stale webhook timestamp', - timestamp: new Date().toISOString() - }); - return; - } - LogEngine.warn('Webhook skew exceeds window (observe-only, not rejected)', { - eventId: req.body?.eventId, - skewSeconds, - maxSkewSeconds: config.webhookMaxSkewSeconds, - enforce: false, - webhookTimestamp: rawWebhookTimestamp, - serverTime: now - }); - } else { - LogEngine.debug('Webhook timestamp within skew window', { - eventId: req.body?.eventId, - skewSeconds, - maxSkewSeconds: config.webhookMaxSkewSeconds - }); + if (skewMs > maxSkewMs) { + if (config.webhookSkewEnforce) { + LogEngine.warn('Webhook rejected - stale timestamp', { + eventId: req.body?.eventId, + skewSeconds, + maxSkewSeconds: config.webhookMaxSkewSeconds, + enforce: true, + webhookTimestamp: rawWebhookTimestamp, + serverTime: now, + }); + res.status(403).json({ + error: 'Stale webhook timestamp', + timestamp: new Date().toISOString(), + }); + return; } + LogEngine.warn('Webhook skew exceeds window (observe-only, not rejected)', { + eventId: req.body?.eventId, + skewSeconds, + maxSkewSeconds: config.webhookMaxSkewSeconds, + enforce: false, + webhookTimestamp: rawWebhookTimestamp, + serverTime: now, + }); + } else { + LogEngine.debug('Webhook timestamp within skew window', { + eventId: req.body?.eventId, + skewSeconds, + maxSkewSeconds: config.webhookMaxSkewSeconds, + }); + } - next(); + next(); }; diff --git a/src/middleware/validation.test.ts b/src/middleware/validation.test.ts index ec79a2f..d9ab3aa 100644 --- a/src/middleware/validation.test.ts +++ b/src/middleware/validation.test.ts @@ -11,9 +11,9 @@ const runMiddleware = async (body: Record) => { json: (payload: unknown) => { state.body = payload; return payload; - } + }, }; - } + }, } as any; const validationChains = validateEvent.slice(0, -1); @@ -38,7 +38,7 @@ describe('validateEvent middleware chain', () => { event: 'message_created', eventId: 'evt-1', eventTimestamp: Date.now(), - webhookTimestamp: Date.now() + webhookTimestamp: Date.now(), }); expect(state.status).toBeUndefined(); expect(state.nextCalled).toBe(true); @@ -48,7 +48,7 @@ describe('validateEvent middleware chain', () => { const state = await runMiddleware({ eventId: 'evt-1', eventTimestamp: Date.now(), - webhookTimestamp: Date.now() + webhookTimestamp: Date.now(), }); expect(state.status).toBe(400); }); @@ -57,7 +57,7 @@ describe('validateEvent middleware chain', () => { const state = await runMiddleware({ event: 'message_created', eventTimestamp: Date.now(), - webhookTimestamp: Date.now() + webhookTimestamp: Date.now(), }); expect(state.status).toBe(400); }); @@ -66,7 +66,7 @@ describe('validateEvent middleware chain', () => { const state = await runMiddleware({ event: 'message_created', eventId: 'evt-1', - webhookTimestamp: Date.now() + webhookTimestamp: Date.now(), }); expect(state.status).toBe(400); }); @@ -75,7 +75,7 @@ describe('validateEvent middleware chain', () => { const state = await runMiddleware({ event: 'message_created', eventId: 'evt-1', - eventTimestamp: Date.now() + eventTimestamp: Date.now(), }); expect(state.status).toBe(400); }); diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts index 635db4e..b4ea1b8 100644 --- a/src/middleware/validation.ts +++ b/src/middleware/validation.ts @@ -1,33 +1,45 @@ -import { body, validationResult, ValidationChain } from 'express-validator'; -import { Request, Response, NextFunction } from 'express'; +import { NextFunction, Request, Response } from 'express'; +import { body, ValidationChain, validationResult } from 'express-validator'; import { ErrorResponse } from '../types'; const validationRules: ValidationChain[] = [ - body('event') - .exists().withMessage('Event type is required') - .isString().withMessage('Event type must be a string'), - body('eventId') - .exists().withMessage('Event ID is required') - .isString().withMessage('Event ID must be a string'), - body('eventTimestamp') - .exists().withMessage('Event timestamp is required') - .isNumeric().withMessage('Event timestamp must be a number'), - body('webhookTimestamp') - .exists().withMessage('Webhook timestamp is required') - .isNumeric().withMessage('Webhook timestamp must be a number') + body('event') + .exists() + .withMessage('Event type is required') + .isString() + .withMessage('Event type must be a string'), + body('eventId') + .exists() + .withMessage('Event ID is required') + .isString() + .withMessage('Event ID must be a string'), + body('eventTimestamp') + .exists() + .withMessage('Event timestamp is required') + .isNumeric() + .withMessage('Event timestamp must be a number'), + body('webhookTimestamp') + .exists() + .withMessage('Webhook timestamp is required') + .isNumeric() + .withMessage('Webhook timestamp must be a number'), ]; -const handleValidationErrors = (req: Request, res: Response, next: NextFunction): void => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - res.status(400).json({ - error: 'Validation failed', - details: errors.array(), - timestamp: new Date().toISOString() - }); - return; - } - next(); +const handleValidationErrors = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + error: 'Validation failed', + details: errors.array(), + timestamp: new Date().toISOString(), + }); + return; + } + next(); }; -export const validateEvent = [...validationRules, handleValidationErrors]; \ No newline at end of file +export const validateEvent = [...validationRules, handleValidationErrors]; diff --git a/src/services/redisService.test.ts b/src/services/redisService.test.ts index bd739d8..dcc622e 100644 --- a/src/services/redisService.test.ts +++ b/src/services/redisService.test.ts @@ -23,7 +23,7 @@ describe('RedisService', () => { exists: mock(async () => 0), set: mock(async () => 'OK'), setEx: mock(async () => 'OK'), - quit: mock(async () => undefined) + quit: mock(async () => undefined), }; (service as any).client = fakeClient; }); @@ -44,7 +44,11 @@ describe('RedisService', () => { it('markEventProcessed delegates to setEx', async () => { await service.markEventProcessed('evt-3'); - expect(fakeClient.setEx).toHaveBeenCalledWith('unthread:eventid:evt-3', 259200, 'processed'); + expect(fakeClient.setEx).toHaveBeenCalledWith( + 'unthread:eventid:evt-3', + 259200, + 'processed', + ); }); it('publishEvent pushes serialized message to redis list', async () => { @@ -56,9 +60,9 @@ describe('RedisService', () => { originalEvent: 'message_created', eventId: 'evt-4', eventTimestamp: Date.now(), - webhookTimestamp: Date.now() + webhookTimestamp: Date.now(), }, - timestamp: Date.now() + timestamp: Date.now(), }); expect(result).toBe(1); expect(fakeClient.lPush).toHaveBeenCalled(); diff --git a/src/services/redisService.ts b/src/services/redisService.ts index 96787ad..29beb70 100644 --- a/src/services/redisService.ts +++ b/src/services/redisService.ts @@ -1,134 +1,152 @@ import { LogEngine } from '@wgtechlabs/log-engine'; -import { client, redisEventConfig } from '../config/redis'; import { config } from '../config/env'; +import { client, redisEventConfig } from '../config/redis'; import { RedisQueueMessage } from '../types'; export class RedisService { - private client: typeof client; + private client: typeof client; - constructor() { - this.client = client; - } + constructor() { + this.client = client; + } - async connect(): Promise { - try { - // Check if already connected - if (this.client.isOpen) { - LogEngine.debug('Redis connection already established'); - return; - } - - LogEngine.debug('Attempting Redis connection...'); - - // Set a timeout for the connection attempt - const connectPromise = this.client.connect(); - const timeoutPromise = new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error('Redis connection timeout after 10 seconds')), 10000); - }); - - await Promise.race([connectPromise, timeoutPromise]); - - // Connection success is logged by the 'ready' event handler in redis.ts - } catch (err) { - LogEngine.error(`Redis connection failed: ${err}`); - throw err; - } - } + async connect(): Promise { + try { + // Check if already connected + if (this.client.isOpen) { + LogEngine.debug('Redis connection already established'); + return; + } - isConnected(): boolean { - return this.client.isOpen; - } + LogEngine.debug('Attempting Redis connection...'); - async publishEvent(event: RedisQueueMessage): Promise { - const queueName = config.unthreadQueueName; - - try { - const eventJson = JSON.stringify(event); - - // Log the complete transformed event data - LogEngine.debug(`TRANSFORMED WEBHOOK EVENT:`, { - eventId: event.data?.eventId || 'unknown', - completeTransformedData: event - }); - - // Use Redis LIST for FIFO queue (LPUSH + BRPOP pattern) - const result = await this.client.lPush(queueName, eventJson); - - LogEngine.info(`Event successfully queued: ${event.data?.eventId || 'unknown'} -> ${queueName} (${result} items in queue)`); - return result; - } catch (err) { - LogEngine.error(`Error publishing event to queue: ${err}`); - throw err; - } - } + // Set a timeout for the connection attempt + const connectPromise = this.client.connect(); + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout( + () => reject(new Error('Redis connection timeout after 10 seconds')), + 10000, + ); + }); - /** - * Check if a key exists in Redis - * @param prefix - Key prefix (e.g., eventId or fingerprint prefix) - * @param id - Unique identifier to check - * @returns Promise - true if key exists - */ - private async keyExists(prefix: string, id: string): Promise { - const key = `${prefix}${id}`; - const exists = await this.client.exists(key); - return exists === 1; - } + await Promise.race([connectPromise, timeoutPromise]); - /** - * Mark a key as processed with automatic expiration - * @param prefix - Key prefix (e.g., eventId or fingerprint prefix) - * @param id - Unique identifier to mark - * @param ttlSeconds - Time to live in seconds (default: 3 days from config) - */ - private async markKey(prefix: string, id: string, ttlSeconds?: number): Promise { - const key = `${prefix}${id}`; - const ttl = ttlSeconds || redisEventConfig.eventTtl; - await this.client.setEx(key, ttl, 'processed'); + // Connection success is logged by the 'ready' event handler in redis.ts + } catch (err) { + LogEngine.error(`Redis connection failed: ${err}`); + throw err; } + } - /** - * Check if webhook event already exists (duplicate detection) - * @param eventId - Unique event identifier - * @returns Promise - true if event exists - */ - async eventExists(eventId: string): Promise { - return this.keyExists(redisEventConfig.keyPrefix, eventId); - } + isConnected(): boolean { + return this.client.isOpen; + } - /** - * Mark webhook event as processed with automatic expiration - * @param eventId - Unique event identifier - * @param ttlSeconds - Time to live in seconds (default: 3 days from config) - */ - async markEventProcessed(eventId: string, ttlSeconds?: number): Promise { - return this.markKey(redisEventConfig.keyPrefix, eventId, ttlSeconds); - } + async publishEvent(event: RedisQueueMessage): Promise { + const queueName = config.unthreadQueueName; - /** - * Atomically claim a composite fingerprint slot (SET NX pattern). - * Combines existence check and marking into a single atomic Redis operation, - * eliminating the race window between check and mark. - * - * @param fingerprint - Composite key: eventTimestamp:event:data.id - * @param ttlSeconds - Time to live in seconds (default: 3 days from config) - * @returns Promise - true if claimed (new), false if already exists (duplicate) - */ - async claimFingerprint(fingerprint: string, ttlSeconds?: number): Promise { - const key = `${redisEventConfig.fingerprintPrefix}${fingerprint}`; - const ttl = ttlSeconds || redisEventConfig.eventTtl; - const result = await this.client.set(key, 'processed', { EX: ttl, NX: true }); - return result !== null; + try { + const eventJson = JSON.stringify(event); + + // Log the complete transformed event data + LogEngine.debug(`TRANSFORMED WEBHOOK EVENT:`, { + eventId: event.data?.eventId || 'unknown', + completeTransformedData: event, + }); + + // Use Redis LIST for FIFO queue (LPUSH + BRPOP pattern) + const result = await this.client.lPush(queueName, eventJson); + + LogEngine.info( + `Event successfully queued: ${event.data?.eventId || 'unknown'} -> ${queueName} (${result} items in queue)`, + ); + return result; + } catch (err) { + LogEngine.error(`Error publishing event to queue: ${err}`); + throw err; } + } + + /** + * Check if a key exists in Redis + * @param prefix - Key prefix (e.g., eventId or fingerprint prefix) + * @param id - Unique identifier to check + * @returns Promise - true if key exists + */ + private async keyExists(prefix: string, id: string): Promise { + const key = `${prefix}${id}`; + const exists = await this.client.exists(key); + return exists === 1; + } + + /** + * Mark a key as processed with automatic expiration + * @param prefix - Key prefix (e.g., eventId or fingerprint prefix) + * @param id - Unique identifier to mark + * @param ttlSeconds - Time to live in seconds (default: 3 days from config) + */ + private async markKey( + prefix: string, + id: string, + ttlSeconds?: number, + ): Promise { + const key = `${prefix}${id}`; + const ttl = ttlSeconds || redisEventConfig.eventTtl; + await this.client.setEx(key, ttl, 'processed'); + } + + /** + * Check if webhook event already exists (duplicate detection) + * @param eventId - Unique event identifier + * @returns Promise - true if event exists + */ + async eventExists(eventId: string): Promise { + return this.keyExists(redisEventConfig.keyPrefix, eventId); + } + + /** + * Mark webhook event as processed with automatic expiration + * @param eventId - Unique event identifier + * @param ttlSeconds - Time to live in seconds (default: 3 days from config) + */ + async markEventProcessed( + eventId: string, + ttlSeconds?: number, + ): Promise { + return this.markKey(redisEventConfig.keyPrefix, eventId, ttlSeconds); + } + + /** + * Atomically claim a composite fingerprint slot (SET NX pattern). + * Combines existence check and marking into a single atomic Redis operation, + * eliminating the race window between check and mark. + * + * @param fingerprint - Composite key: eventTimestamp:event:data.id + * @param ttlSeconds - Time to live in seconds (default: 3 days from config) + * @returns Promise - true if claimed (new), false if already exists (duplicate) + */ + async claimFingerprint( + fingerprint: string, + ttlSeconds?: number, + ): Promise { + const key = `${redisEventConfig.fingerprintPrefix}${fingerprint}`; + const ttl = ttlSeconds || redisEventConfig.eventTtl; + const result = await this.client.set(key, 'processed', { + EX: ttl, + NX: true, + }); + return result !== null; + } - async close(): Promise { - try { - if (this.client.isOpen) { - await this.client.quit(); - LogEngine.log('Redis connection closed'); - } - } catch (err) { - LogEngine.error(`Error closing Redis connection: ${err}`); - throw err; - } + async close(): Promise { + try { + if (this.client.isOpen) { + await this.client.quit(); + LogEngine.log('Redis connection closed'); + } + } catch (err) { + LogEngine.error(`Error closing Redis connection: ${err}`); + throw err; } -} \ No newline at end of file + } +} diff --git a/src/services/webhookService.test.ts b/src/services/webhookService.test.ts index 9564a82..ee4edc7 100644 --- a/src/services/webhookService.test.ts +++ b/src/services/webhookService.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, +} from 'bun:test'; import { UnthreadWebhookEvent } from '../types'; process.env.TARGET_PLATFORM = 'whatsapp'; @@ -11,7 +19,9 @@ beforeAll(async () => { ({ WebhookService } = await import('./webhookService')); }); -const buildEvent = (overrides: Partial = {}): UnthreadWebhookEvent => ({ +const buildEvent = ( + overrides: Partial = {}, +): UnthreadWebhookEvent => ({ event: 'message_created', eventId: 'evt-1', eventTimestamp: 1772463244428, @@ -20,9 +30,9 @@ const buildEvent = (overrides: Partial = {}): UnthreadWebh id: 'msg-1', conversationId: 'conv-1', content: 'hello', - ...overrides.data + ...overrides.data, }, - ...overrides + ...overrides, }); describe('WebhookService', () => { @@ -44,7 +54,7 @@ describe('WebhookService', () => { eventExists: mock(async () => false), claimFingerprint: mock(async () => true), markEventProcessed: mock(async () => undefined), - publishEvent: mock(async () => 1) + publishEvent: mock(async () => 1), }; (service as any).redisService = fakeRedisService; }); @@ -56,21 +66,48 @@ describe('WebhookService', () => { it('validates supported events and rejects malformed events', () => { expect(service.validateEvent(buildEvent()).isValid).toBe(true); - const missingFields = service.validateEvent({ event: 'message_created' } as UnthreadWebhookEvent); + const missingFields = service.validateEvent({ + event: 'message_created', + } as UnthreadWebhookEvent); expect(missingFields.isValid).toBe(false); expect(missingFields.errors).toContain('Missing required field: eventId'); - const unsupportedEvent = service.validateEvent(buildEvent({ event: 'unsupported_event' as never })); + const unsupportedEvent = service.validateEvent( + buildEvent({ event: 'unsupported_event' as never }), + ); expect(unsupportedEvent.isValid).toBe(false); - expect(unsupportedEvent.errors).toContain('Unsupported event type: unsupported_event'); + expect(unsupportedEvent.errors).toContain( + 'Unsupported event type: unsupported_event', + ); }); it('detects core platform source branches', () => { - expect((service as any).detectPlatformSource(buildEvent({ event: 'conversation_updated' }))).toBe('dashboard'); - expect((service as any).detectPlatformSource(buildEvent({ data: { botName: '@bot' } }))).toBe('whatsapp'); - expect((service as any).detectPlatformSource(buildEvent({ data: { botName: '+14155238886' } }))).toBe('whatsapp'); - expect((service as any).detectPlatformSource(buildEvent({ data: { botName: 'Support Agent' } }))).toBe('dashboard'); - expect((service as any).detectPlatformSource(buildEvent({ data: { id: 'msg-1', conversationId: 'conv-1' } }))).toBe('unknown'); + expect( + (service as any).detectPlatformSource( + buildEvent({ event: 'conversation_updated' }), + ), + ).toBe('dashboard'); + expect( + (service as any).detectPlatformSource( + buildEvent({ data: { botName: '@bot' } }), + ), + ).toBe('whatsapp'); + expect( + (service as any).detectPlatformSource( + // biome-ignore lint/security/noSecrets: Twilio WhatsApp sandbox test number, not a secret + buildEvent({ data: { botName: '+14155238886' } }), + ), + ).toBe('whatsapp'); + expect( + (service as any).detectPlatformSource( + buildEvent({ data: { botName: 'Support Agent' } }), + ), + ).toBe('dashboard'); + expect( + (service as any).detectPlatformSource( + buildEvent({ data: { id: 'msg-1', conversationId: 'conv-1' } }), + ), + ).toBe('unknown'); }); it('generates attachment metadata only when files are present', () => { @@ -79,42 +116,62 @@ describe('WebhookService', () => { fileCount: 0, totalSize: 0, types: [], - names: [] + names: [], }); - const metadata = (service as any).generateAttachmentMetadata(buildEvent({ - data: { - files: [ - { name: 'a.txt', size: 5, mimetype: 'text/plain' }, - { title: 'b.png', size: 10, filetype: 'image/png' } - ] - } - })); + const metadata = (service as any).generateAttachmentMetadata( + buildEvent({ + data: { + files: [ + { name: 'a.txt', size: 5, mimetype: 'text/plain' }, + { title: 'b.png', size: 10, filetype: 'image/png' }, + ], + }, + }), + ); expect(metadata).toEqual({ hasFiles: true, fileCount: 2, totalSize: 15, types: ['text/plain', 'image/png'], - names: ['a.txt', 'b.png'] + names: ['a.txt', 'b.png'], }); }); it('generates stable fingerprints when a stable data id exists', () => { - expect((service as any).generateFingerprint(buildEvent())).toBe('message_created:msg-1'); - expect((service as any).generateFingerprint(buildEvent({ data: { conversationId: 'conv-1' } }))).toBeNull(); - expect((service as any).generateFingerprint(buildEvent({ - event: 'conversation_created', - data: { id: 'conv-1' } - }))).toBe('conversation_created:conv-1'); - expect((service as any).generateFingerprint(buildEvent({ - event: 'conversation_updated', - data: { id: 'conv-1', updatedAt: '2026-04-29T09:32:01.028Z' } - }))).toBe('conversation_updated:conv-1:2026-04-29T09:32:01.028Z'); - expect((service as any).generateFingerprint(buildEvent({ - event: 'conversation_updated', - data: { id: 'conv-1' } - }))).toBeNull(); + expect((service as any).generateFingerprint(buildEvent())).toBe( + 'message_created:msg-1', + ); + expect( + (service as any).generateFingerprint( + buildEvent({ data: { conversationId: 'conv-1' } }), + ), + ).toBeNull(); + expect( + (service as any).generateFingerprint( + buildEvent({ + event: 'conversation_created', + data: { id: 'conv-1' }, + }), + ), + ).toBe('conversation_created:conv-1'); + expect( + (service as any).generateFingerprint( + buildEvent({ + event: 'conversation_updated', + data: { id: 'conv-1', updatedAt: '2026-04-29T09:32:01.028Z' }, + }), + ), + ).toBe('conversation_updated:conv-1:2026-04-29T09:32:01.028Z'); + expect( + (service as any).generateFingerprint( + buildEvent({ + event: 'conversation_updated', + data: { id: 'conv-1' }, + }), + ), + ).toBeNull(); }); it('returns early when the eventId already exists', async () => { @@ -130,10 +187,16 @@ describe('WebhookService', () => { it('marks eventId when fingerprint claim reports duplicate', async () => { fakeRedisService.claimFingerprint.mockResolvedValueOnce(false); - await service.processEvent(buildEvent({ eventId: 'evt-retry', data: { id: 'msg-duplicate' } })); + await service.processEvent( + buildEvent({ eventId: 'evt-retry', data: { id: 'msg-duplicate' } }), + ); - expect(fakeRedisService.claimFingerprint).toHaveBeenCalledWith('message_created:msg-duplicate'); - expect(fakeRedisService.markEventProcessed).toHaveBeenCalledWith('evt-retry'); + expect(fakeRedisService.claimFingerprint).toHaveBeenCalledWith( + 'message_created:msg-duplicate', + ); + expect(fakeRedisService.markEventProcessed).toHaveBeenCalledWith( + 'evt-retry', + ); expect(fakeRedisService.publishEvent).not.toHaveBeenCalled(); }); @@ -142,23 +205,27 @@ describe('WebhookService', () => { eventId: 'evt-new', data: { id: 'msg-new', - metadata: { event_payload: { conversationUpdates: {} } } - } + metadata: { event_payload: { conversationUpdates: {} } }, + }, }); await service.processEvent(event); - expect(fakeRedisService.claimFingerprint).toHaveBeenCalledWith('message_created:msg-new'); - expect(fakeRedisService.publishEvent).toHaveBeenCalledWith(expect.objectContaining({ - platform: 'unthread', - targetPlatform: 'whatsapp', - type: 'message_created', - sourcePlatform: 'dashboard', - data: expect.objectContaining({ - eventId: 'evt-new', - fingerprint: 'message_created:msg-new' - }) - })); + expect(fakeRedisService.claimFingerprint).toHaveBeenCalledWith( + 'message_created:msg-new', + ); + expect(fakeRedisService.publishEvent).toHaveBeenCalledWith( + expect.objectContaining({ + platform: 'unthread', + targetPlatform: 'whatsapp', + type: 'message_created', + sourcePlatform: 'dashboard', + data: expect.objectContaining({ + eventId: 'evt-new', + fingerprint: 'message_created:msg-new', + }), + }), + ); expect(fakeRedisService.markEventProcessed).toHaveBeenCalledWith('evt-new'); }); -}); \ No newline at end of file +}); diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index 80fd714..15049ce 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -1,356 +1,405 @@ import { LogEngine } from '@wgtechlabs/log-engine'; -import { RedisService } from './redisService'; import { config } from '../config/env'; -import { FileAttachmentCorrelationUtil } from '../utils/fileAttachmentCorrelation'; -import { - UnthreadWebhookEvent, - RedisQueueMessage, - UnthreadEventType, - ValidationResult, - PlatformSource, - AttachmentMetadata +import { + AttachmentMetadata, + PlatformSource, + RedisQueueMessage, + UnthreadEventType, + UnthreadWebhookEvent, + ValidationResult, } from '../types'; +import { FileAttachmentCorrelationUtil } from '../utils/fileAttachmentCorrelation'; +import { RedisService } from './redisService'; export class WebhookService { - private redisService: RedisService; - private fileAttachmentCorrelation: FileAttachmentCorrelationUtil; - - constructor() { - this.redisService = new RedisService(); - this.fileAttachmentCorrelation = new FileAttachmentCorrelationUtil(); - - // Set up callback for processing buffered file events - this.fileAttachmentCorrelation.onBufferedEventReady = async (event, sourcePlatform) => { - try { - await this.continueEventProcessing(event, sourcePlatform); - } catch (error) { - LogEngine.error('Failed to process buffered file attachment event in callback', { - eventId: event.eventId, - sourcePlatform, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }; + private redisService: RedisService; + private fileAttachmentCorrelation: FileAttachmentCorrelationUtil; + + constructor() { + this.redisService = new RedisService(); + this.fileAttachmentCorrelation = new FileAttachmentCorrelationUtil(); + + // Set up callback for processing buffered file events + this.fileAttachmentCorrelation.onBufferedEventReady = async ( + event, + sourcePlatform, + ) => { + try { + await this.continueEventProcessing(event, sourcePlatform); + } catch (error) { + LogEngine.error( + 'Failed to process buffered file attachment event in callback', + { + eventId: event.eventId, + sourcePlatform, + error: error instanceof Error ? error.message : 'Unknown error', + }, + ); + } + }; + } + + private async initializeServices(): Promise { + if (!this.redisService.isConnected()) { + await this.redisService.connect(); } + } - private async initializeServices(): Promise { - if (!this.redisService.isConnected()) { - await this.redisService.connect(); - } + async processEvent(event: UnthreadWebhookEvent): Promise { + const startTime = Date.now(); + + const validationResult = this.validateEvent(event); + if (!validationResult.isValid) { + const details = validationResult.errors?.join(', ') ?? 'unknown errors'; + throw new Error(`Invalid event structure: ${details}`); } - async processEvent(event: UnthreadWebhookEvent): Promise { - const startTime = Date.now(); - - if (!this.validateEvent(event).isValid) { - throw new Error('Invalid event structure'); - } + await this.initializeServices(); - await this.initializeServices(); + // Check for duplicate events by eventId (exact retry) + const eventExists = await this.redisService.eventExists(event.eventId); + if (eventExists) { + LogEngine.info( + `Event already processed - duplicate detected: ${event.eventId}`, + ); + return; + } - // Check for duplicate events by eventId (exact retry) - const eventExists = await this.redisService.eventExists(event.eventId); - if (eventExists) { - LogEngine.info(`Event already processed - duplicate detected: ${event.eventId}`); - return; - } + // Atomically claim fingerprint slot (retry with new eventId detection) + // Uses SET NX to combine check+mark into a single atomic operation + const fingerprint = this.generateFingerprint(event); + if (!fingerprint && event.event !== 'url_verification') { + LogEngine.warn( + `No fingerprint generated for event ${event.eventId} — falling back to eventId-only dedup`, + ); + } + if (fingerprint) { + const claimed = await this.redisService.claimFingerprint(fingerprint); + if (!claimed) { + LogEngine.info( + `Event already processed - fingerprint duplicate detected: ${event.eventId} (fp: ${fingerprint})`, + ); + await this.redisService.markEventProcessed(event.eventId); + return; + } + } - // Atomically claim fingerprint slot (retry with new eventId detection) - // Uses SET NX to combine check+mark into a single atomic operation - const fingerprint = this.generateFingerprint(event); - if (!fingerprint && event.event !== 'url_verification') { - LogEngine.warn(`No fingerprint generated for event ${event.eventId} — falling back to eventId-only dedup`); - } - if (fingerprint) { - const claimed = await this.redisService.claimFingerprint(fingerprint); - if (!claimed) { - LogEngine.info(`Event already processed - fingerprint duplicate detected: ${event.eventId} (fp: ${fingerprint})`); - await this.redisService.markEventProcessed(event.eventId); - return; - } - } + // Detect platform source (enhanced with file attachment correlation) + const sourcePlatform = this.detectPlatformSource(event); - // Detect platform source (enhanced with file attachment correlation) - const sourcePlatform = this.detectPlatformSource(event); - - // Handle buffered events - they will be processed later via callback - if (sourcePlatform === 'buffered') { - // Mark buffered events as processed to prevent duplicate buffering on retries - // Note: fingerprint already claimed atomically above via SET NX - await this.redisService.markEventProcessed(event.eventId); - - const processingTime = Date.now() - startTime; - LogEngine.info('File attachment event buffered for correlation', { - eventId: event.eventId, - fingerprint, - hasFiles: this.fileAttachmentCorrelation.hasFileAttachments(event), - processingTime: `${processingTime}ms` - }); - return; // Event will be processed later when correlation is available - } - - // Continue with normal processing - await this.continueEventProcessing(event, sourcePlatform); - - const totalProcessingTime = Date.now() - startTime; - LogEngine.debug(`Event processing completed`, { - eventId: event.eventId, - fingerprint, - sourcePlatform, - totalProcessingTime: `${totalProcessingTime}ms` - }); + // Handle buffered events - they will be processed later via callback + if (sourcePlatform === 'buffered') { + const processingTime = Date.now() - startTime; + LogEngine.info('File attachment event buffered for correlation', { + eventId: event.eventId, + fingerprint, + hasFiles: this.fileAttachmentCorrelation.hasFileAttachments(event), + processingTime: `${processingTime}ms`, + }); + return; // Event will be processed later when correlation is available } - private transformEvent(unthreadEvent: UnthreadWebhookEvent, sourcePlatform: PlatformSource): RedisQueueMessage { - const targetPlatform = config.targetPlatform; - const attachmentMetadata = this.generateAttachmentMetadata(unthreadEvent); - - const fingerprint = this.generateFingerprint(unthreadEvent); + // Continue with normal processing + await this.continueEventProcessing(event, sourcePlatform); - const message: RedisQueueMessage = { - platform: "unthread", - targetPlatform, - type: unthreadEvent.event, - sourcePlatform, - data: { - ...unthreadEvent.data, - originalEvent: unthreadEvent.event, - eventId: unthreadEvent.eventId, - eventTimestamp: unthreadEvent.eventTimestamp, - webhookTimestamp: unthreadEvent.webhookTimestamp, - ...(fingerprint && { fingerprint }) - }, - timestamp: Date.now() - }; - - // Add attachment metadata if files are present - if (attachmentMetadata.hasFiles) { - message.attachments = attachmentMetadata; - } + const totalProcessingTime = Date.now() - startTime; + LogEngine.debug(`Event processing completed`, { + eventId: event.eventId, + fingerprint, + sourcePlatform, + totalProcessingTime: `${totalProcessingTime}ms`, + }); + } + + private transformEvent( + unthreadEvent: UnthreadWebhookEvent, + sourcePlatform: PlatformSource, + ): RedisQueueMessage { + const targetPlatform = config.targetPlatform; + const attachmentMetadata = this.generateAttachmentMetadata(unthreadEvent); + + const fingerprint = this.generateFingerprint(unthreadEvent); + + const message: RedisQueueMessage = { + platform: 'unthread', + targetPlatform, + type: unthreadEvent.event, + sourcePlatform, + data: { + ...unthreadEvent.data, + originalEvent: unthreadEvent.event, + eventId: unthreadEvent.eventId, + eventTimestamp: unthreadEvent.eventTimestamp, + webhookTimestamp: unthreadEvent.webhookTimestamp, + ...(fingerprint && { fingerprint }), + }, + timestamp: Date.now(), + }; - return message; + // Add attachment metadata if files are present + if (attachmentMetadata.hasFiles) { + message.attachments = attachmentMetadata; } - /** - * Generate rich attachment metadata for easier integration - * - * GUARANTEE: If hasFiles is true, data.files array exists with fileCount items - * GUARANTEE: If hasFiles is false, data.files is empty/missing or attachments field is omitted - */ - private generateAttachmentMetadata(event: UnthreadWebhookEvent): AttachmentMetadata { - const files = event.data?.files; - - // Strict validation: must be a non-empty array - if (!files || !Array.isArray(files) || files.length === 0) { - return { - hasFiles: false, - fileCount: 0, - totalSize: 0, - types: [], - names: [] - }; - } + return message; + } + + /** + * Generate rich attachment metadata for easier integration + * + * GUARANTEE: If hasFiles is true, data.files array exists with fileCount items + * GUARANTEE: If hasFiles is false, data.files is empty/missing or attachments field is omitted + */ + private generateAttachmentMetadata( + event: UnthreadWebhookEvent, + ): AttachmentMetadata { + const files = event.data?.files; - const fileCount = files.length; - const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); - const types = files.map(file => file.mimetype || file.filetype || 'unknown').filter(Boolean); - const names = files.map(file => file.name || file.title || 'unnamed').filter(Boolean); - - // GUARANTEE: If this returns hasFiles: true, data.files exists with fileCount items - return { - hasFiles: true, - fileCount, - totalSize, - types: [...new Set(types)], // Remove duplicates - names - }; + // Strict validation: must be a non-empty array + if (!files || !Array.isArray(files) || files.length === 0) { + return { + hasFiles: false, + fileCount: 0, + totalSize: 0, + types: [], + names: [], + }; } - /** - * Enhanced platform source detection with file attachment correlation - * - If conversation_updated → 'dashboard' (always administrative actions) - * - If from dashboard → 'dashboard' - * - If file attachment with unknown source → attempt correlation or buffer - * - If unknown → 'unknown' - * - Otherwise → use the actual target platform value from environment variable - */ - private detectPlatformSource(event: UnthreadWebhookEvent): PlatformSource { - // Conversation updates are always administrative actions from dashboard - if (event.event === 'conversation_updated') { - LogEngine.debug(`Platform detected via event type: dashboard (${event.eventId}) - conversation updates are administrative actions`); - return 'dashboard'; - } + const fileCount = files.length; + const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); + const types = files + .map((file) => file.mimetype || file.filetype || 'unknown') + .filter(Boolean); + const names = files + .map((file) => file.name || file.title || 'unnamed') + .filter(Boolean); - if (event.event !== 'message_created' || !event.data) { - return 'unknown'; - } + // GUARANTEE: If this returns hasFiles: true, data.files exists with fileCount items + return { + hasFiles: true, + fileCount, + totalSize, + types: [...new Set(types)], // Remove duplicates + names, + }; + } - // Extract platform source using existing logic - const detectedPlatform = this.extractBasicPlatformSource(event); - - // Enhanced logic for file attachment correlation - const hasFileAttachments = this.fileAttachmentCorrelation.hasFileAttachments(event); - const isSourceConfirmed = this.fileAttachmentCorrelation.isSourcePlatformConfirmed(detectedPlatform); - - if (hasFileAttachments && !isSourceConfirmed) { - // This is a file attachment event with unknown source - try correlation - LogEngine.debug(`File attachment detected with unknown source, attempting correlation (${event.eventId})`); - return this.fileAttachmentCorrelation.correlateFileEvent(event); - } - - if (isSourceConfirmed && !hasFileAttachments) { - // This is a message event with confirmed source - cache for correlation - LogEngine.debug(`Message event with confirmed source, caching for correlation (${event.eventId})`); - this.fileAttachmentCorrelation.cacheMessageEvent(event, detectedPlatform); - } - - return detectedPlatform; + /** + * Enhanced platform source detection with file attachment correlation + * - If conversation_updated → 'dashboard' (always administrative actions) + * - If from dashboard → 'dashboard' + * - If file attachment with unknown source → attempt correlation or buffer + * - If unknown → 'unknown' + * - Otherwise → use the actual target platform value from environment variable + */ + private detectPlatformSource(event: UnthreadWebhookEvent): PlatformSource { + // Conversation updates are always administrative actions from dashboard + if (event.event === 'conversation_updated') { + LogEngine.debug( + `Platform detected via event type: dashboard (${event.eventId}) - conversation updates are administrative actions`, + ); + return 'dashboard'; } - /** - * Extract basic platform source using existing detection logic - * This is the original detectPlatformSource logic extracted for reuse - */ - private extractBasicPlatformSource(event: UnthreadWebhookEvent): PlatformSource { - // PRIMARY DETECTION: conversationUpdates field analysis (100% reliable) - const hasConversationUpdates = event.data?.metadata?.event_payload?.conversationUpdates !== undefined; - - if (hasConversationUpdates) { - LogEngine.debug(`Platform detected via conversationUpdates: dashboard (${event.eventId})`); - return 'dashboard'; - } else { - // Check if metadata exists but conversationUpdates is missing - if (event.data?.metadata?.event_payload && !hasConversationUpdates) { - LogEngine.debug(`Platform detected via missing conversationUpdates: ${config.targetPlatform} (${event.eventId})`); - return config.targetPlatform; - } - } + if (event.event !== 'message_created' || !event.data) { + return 'unknown'; + } - // SECONDARY DETECTION: botName pattern matching (fallback) - // Supports multiple platform patterns: - // - @username format (e.g., Discord bots) - // - Phone number format (e.g., WhatsApp via Twilio: +1234567890) - if (event.data?.botName) { - const botName = event.data.botName; - if (typeof botName === 'string') { - const isAtMention = botName.startsWith('@'); - const isPhoneNumber = /^\+?\d[\d\s\-()]{6,}$/.test(botName.trim()); - - if (isAtMention || isPhoneNumber) { - const pattern = isAtMention ? '@mention' : 'phone number'; - LogEngine.debug(`Platform detected via botName ${pattern} pattern: ${config.targetPlatform} (${event.eventId})`); - return config.targetPlatform; - } else { - LogEngine.debug(`Platform detected via botName pattern: dashboard (${event.eventId})`); - return 'dashboard'; - } - } - } + // Extract platform source using existing logic + const detectedPlatform = this.extractBasicPlatformSource(event); + + // Enhanced logic for file attachment correlation + const hasFileAttachments = + this.fileAttachmentCorrelation.hasFileAttachments(event); + const isSourceConfirmed = + this.fileAttachmentCorrelation.isSourcePlatformConfirmed( + detectedPlatform, + ); - // FALLBACK: Unknown if no reliable indicators found - LogEngine.warn(`Unable to detect platform source for event ${event.eventId} - insufficient indicators`); - return 'unknown'; + if (hasFileAttachments && !isSourceConfirmed) { + // This is a file attachment event with unknown source - try correlation + LogEngine.debug( + `File attachment detected with unknown source, attempting correlation (${event.eventId})`, + ); + return this.fileAttachmentCorrelation.correlateFileEvent(event); } - validateEvent(event: UnthreadWebhookEvent): ValidationResult { - const requiredFields: (keyof UnthreadWebhookEvent)[] = ['event', 'eventId', 'eventTimestamp', 'webhookTimestamp']; - const errors: string[] = []; - - // Check if all required fields are present - for (const field of requiredFields) { - // eslint-disable-next-line security/detect-object-injection - if (!event[field]) { - const error = `Missing required field: ${field}`; - LogEngine.error(error); - errors.push(error); - } - } - - // Validate supported event types - const supportedEvents: UnthreadEventType[] = [ - 'url_verification', - 'conversation_created', - 'conversation_updated', - 'conversation_deleted', - 'message_created' - ]; - - if (!supportedEvents.includes(event.event)) { - const error = `Unsupported event type: ${event.event}`; - LogEngine.error(error); - errors.push(error); - } - - return { - isValid: errors.length === 0, - errors: errors.length > 0 ? errors : undefined - }; + if (isSourceConfirmed && !hasFileAttachments) { + // This is a message event with confirmed source - cache for correlation + LogEngine.debug( + `Message event with confirmed source, caching for correlation (${event.eventId})`, + ); + this.fileAttachmentCorrelation.cacheMessageEvent(event, detectedPlatform); + } + + return detectedPlatform; + } + + /** + * Extract basic platform source using existing detection logic + * This is the original detectPlatformSource logic extracted for reuse + */ + private extractBasicPlatformSource( + event: UnthreadWebhookEvent, + ): PlatformSource { + // PRIMARY DETECTION: conversationUpdates field analysis (100% reliable) + const hasConversationUpdates = + event.data?.metadata?.event_payload?.conversationUpdates !== undefined; + + if (hasConversationUpdates) { + LogEngine.debug( + `Platform detected via conversationUpdates: dashboard (${event.eventId})`, + ); + return 'dashboard'; + } else { + // Check if metadata exists but conversationUpdates is missing + if (event.data?.metadata?.event_payload && !hasConversationUpdates) { + LogEngine.debug( + `Platform detected via missing conversationUpdates: ${config.targetPlatform} (${event.eventId})`, + ); + return config.targetPlatform; + } } - /** - * Continue processing a buffered file event with the correlated source platform - * This method is called by the correlation utility when a buffered event is ready - */ - private async continueEventProcessing(event: UnthreadWebhookEvent, sourcePlatform: string): Promise { - try { - LogEngine.info('Processing buffered file attachment event', { - eventId: event.eventId, - sourcePlatform, - correlationSuccess: sourcePlatform !== 'unknown' - }); - - // Transform and queue the event with the correlated source platform - const transformedEvent = this.transformEvent(event, sourcePlatform); - await this.redisService.publishEvent(transformedEvent); - - // Mark eventId as processed - await this.redisService.markEventProcessed(event.eventId); - - } catch (error) { - LogEngine.error('Failed to process buffered file attachment event', { - eventId: event.eventId, - sourcePlatform, - error: error instanceof Error ? error.message : 'Unknown error' - }); - throw error; + // SECONDARY DETECTION: botName pattern matching (fallback) + // Supports multiple platform patterns: + // - @username format (e.g., Discord bots) + // - Phone number format (e.g., WhatsApp via Twilio: +1234567890) + if (event.data?.botName) { + const botName = event.data.botName; + if (typeof botName === 'string') { + const isAtMention = botName.startsWith('@'); + const isPhoneNumber = /^\+?\d[\d\s\-()]{6,}$/.test(botName.trim()); + + if (isAtMention || isPhoneNumber) { + const pattern = isAtMention ? '@mention' : 'phone number'; + LogEngine.debug( + `Platform detected via botName ${pattern} pattern: ${config.targetPlatform} (${event.eventId})`, + ); + return config.targetPlatform; + } else { + LogEngine.debug( + `Platform detected via botName pattern: dashboard (${event.eventId})`, + ); + return 'dashboard'; } + } } - /** - * Clean up resources when service is destroyed - */ - destroy(): void { - this.fileAttachmentCorrelation.destroy(); + // FALLBACK: Unknown if no reliable indicators found + LogEngine.warn( + `Unable to detect platform source for event ${event.eventId} - insufficient indicators`, + ); + return 'unknown'; + } + + validateEvent(event: UnthreadWebhookEvent): ValidationResult { + const requiredFields: (keyof UnthreadWebhookEvent)[] = [ + 'event', + 'eventId', + 'eventTimestamp', + 'webhookTimestamp', + ]; + const errors: string[] = []; + + // Check if all required fields are present + for (const field of requiredFields) { + // eslint-disable-next-line security/detect-object-injection + if (!event[field]) { + const error = `Missing required field: ${field}`; + LogEngine.error(error); + errors.push(error); + } } - /** - * Generate a composite fingerprint for retry deduplication. - * Format: `{event}:{data.id}` for logical create/delete events. - * Format: `{event}:{data.id}:{changeMarker}` for conversation_updated events, - * where changeMarker is derived from stable update fields (updatedAt, - * statusUpdatedAt, or status). Returns null when no stable marker is - * available to avoid letting volatile retries bypass deduplication. - * - * This catches Unthread retries that assign a new eventId to the same logical event. - * Returns null if insufficient data to generate a meaningful fingerprint. - */ - private generateFingerprint(event: UnthreadWebhookEvent): string | null { - const eventType = event.event; - const dataId = event.data?.id; - - if (!eventType || !dataId) { - return null; - } + // Validate supported event types + const supportedEvents: UnthreadEventType[] = [ + 'url_verification', + 'conversation_created', + 'conversation_updated', + 'conversation_deleted', + 'message_created', + ]; + + if (!supportedEvents.includes(event.event)) { + const error = `Unsupported event type: ${event.event}`; + LogEngine.error(error); + errors.push(error); + } - if (eventType === 'conversation_updated') { - const changeMarker = - event.data?.updatedAt || - event.data?.statusUpdatedAt || - event.data?.status; + return { + isValid: errors.length === 0, + errors: errors.length > 0 ? errors : undefined, + }; + } - return changeMarker ? `${eventType}:${dataId}:${changeMarker}` : null; - } + /** + * Continue processing a buffered file event with the correlated source platform + * This method is called by the correlation utility when a buffered event is ready + */ + private async continueEventProcessing( + event: UnthreadWebhookEvent, + sourcePlatform: PlatformSource, + ): Promise { + try { + LogEngine.info('Processing buffered file attachment event', { + eventId: event.eventId, + sourcePlatform, + correlationSuccess: sourcePlatform !== 'unknown', + }); - return `${eventType}:${dataId}`; + // Transform and queue the event with the correlated source platform + const transformedEvent = this.transformEvent(event, sourcePlatform); + await this.redisService.publishEvent(transformedEvent); + + // Mark eventId as processed + await this.redisService.markEventProcessed(event.eventId); + } catch (error) { + LogEngine.error('Failed to process buffered file attachment event', { + eventId: event.eventId, + sourcePlatform, + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; } + } + + /** + * Clean up resources when service is destroyed + */ + destroy(): void { + this.fileAttachmentCorrelation.destroy(); + } + + /** + * Generate a composite fingerprint for retry deduplication. + * Format: `{event}:{data.id}` for logical create/delete events. + * Format: `{event}:{data.id}:{changeMarker}` for conversation_updated events, + * where changeMarker is derived from stable update fields (updatedAt, + * statusUpdatedAt, or status). Returns null when no stable marker is + * available to avoid letting volatile retries bypass deduplication. + * + * This catches Unthread retries that assign a new eventId to the same logical event. + * Returns null if insufficient data to generate a meaningful fingerprint. + */ + private generateFingerprint(event: UnthreadWebhookEvent): string | null { + const eventType = event.event; + const dataId = event.data?.id; + + if (!eventType || !dataId) { + return null; + } + + if (eventType === 'conversation_updated') { + const changeMarker = + event.data?.updatedAt || + event.data?.statusUpdatedAt || + event.data?.status; + + return changeMarker ? `${eventType}:${dataId}:${changeMarker}` : null; + } + + return `${eventType}:${dataId}`; + } } diff --git a/src/types/index.ts b/src/types/index.ts index 8e2ef5f..357f4e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; // Unthread webhook event types -export type UnthreadEventType = +export type UnthreadEventType = | 'url_verification' | 'conversation_created' | 'conversation_updated' @@ -29,7 +29,10 @@ export interface UrlVerificationEvent extends UnthreadWebhookEvent { // Conversation events export interface ConversationEvent extends UnthreadWebhookEvent { - event: 'conversation_created' | 'conversation_updated' | 'conversation_deleted'; + event: + | 'conversation_created' + | 'conversation_updated' + | 'conversation_deleted'; data: { id: string; title?: string; @@ -60,11 +63,15 @@ export interface MessageEvent extends UnthreadWebhookEvent { export type PlatformSource = string; // Union type for all webhook events -export type WebhookEvent = UrlVerificationEvent | ConversationEvent | MessageEvent; +export type WebhookEvent = + | UrlVerificationEvent + | ConversationEvent + | MessageEvent; // Extended Express Request with webhook-specific properties // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface WebhookRequest extends Request { +export interface WebhookRequest + extends Request { rawBody: string; } diff --git a/src/utils/fileAttachmentCorrelation.test.ts b/src/utils/fileAttachmentCorrelation.test.ts index a2df6c4..f67d668 100644 --- a/src/utils/fileAttachmentCorrelation.test.ts +++ b/src/utils/fileAttachmentCorrelation.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, mock } from 'bun:test'; -import { FileAttachmentCorrelationUtil } from './fileAttachmentCorrelation'; import { UnthreadWebhookEvent } from '../types'; +import { FileAttachmentCorrelationUtil } from './fileAttachmentCorrelation'; mock.module('@wgtechlabs/log-engine', () => ({ LogEngine: { @@ -8,11 +8,13 @@ mock.module('@wgtechlabs/log-engine', () => ({ debug: mock(() => undefined), warn: mock(() => undefined), error: mock(() => undefined), - log: mock(() => undefined) - } + log: mock(() => undefined), + }, })); -const makeEvent = (overrides: Partial = {}): UnthreadWebhookEvent => ({ +const makeEvent = ( + overrides: Partial = {}, +): UnthreadWebhookEvent => ({ event: 'message_created', eventId: 'evt-1', eventTimestamp: Date.now(), @@ -22,9 +24,9 @@ const makeEvent = (overrides: Partial = {}): UnthreadWebho conversationId: 'conv-1', threadTs: 't1', channelId: 'c1', - teamId: 'tm1' + teamId: 'tm1', }, - ...overrides + ...overrides, }); describe('FileAttachmentCorrelationUtil', () => { @@ -40,8 +42,14 @@ describe('FileAttachmentCorrelationUtil', () => { it('generateCorrelationKey handles empty, partial, and full data', () => { const util = new FileAttachmentCorrelationUtil(); instances.push(util); - expect(util.generateCorrelationKey(makeEvent({ data: undefined })).length).toBe(0); - expect(util.generateCorrelationKey(makeEvent({ data: { conversationId: 'only-one' } })).length).toBe(0); + expect( + util.generateCorrelationKey(makeEvent({ data: undefined })).length, + ).toBe(0); + expect( + util.generateCorrelationKey( + makeEvent({ data: { conversationId: 'only-one' } }), + ).length, + ).toBe(0); expect(util.generateCorrelationKey(makeEvent())).toBe('conv-1-t1-c1-tm1'); }); @@ -49,13 +57,15 @@ describe('FileAttachmentCorrelationUtil', () => { const util = new FileAttachmentCorrelationUtil(); instances.push(util); util.cacheMessageEvent(makeEvent({ eventId: 'msg-source' }), 'whatsapp'); - const correlated = util.correlateFileEvent(makeEvent({ - eventId: 'file-1', - data: { - ...makeEvent().data, - files: [{ name: 'a.txt' }] - } - })); + const correlated = util.correlateFileEvent( + makeEvent({ + eventId: 'file-1', + data: { + ...makeEvent().data, + files: [{ name: 'a.txt' }], + }, + }), + ); expect(correlated).toBe('whatsapp'); }); @@ -67,16 +77,21 @@ describe('FileAttachmentCorrelationUtil', () => { const callback = mock(async () => undefined); util.onBufferedEventReady = callback; - const result = util.correlateFileEvent(makeEvent({ - eventId: 'file-timeout', - data: { - ...makeEvent().data, - files: [{ name: 'b.txt' }] - } - })); + const result = util.correlateFileEvent( + makeEvent({ + eventId: 'file-timeout', + data: { + ...makeEvent().data, + files: [{ name: 'b.txt' }], + }, + }), + ); expect(result).toBe('buffered'); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(callback).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'file-timeout' }), 'unknown'); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ eventId: 'file-timeout' }), + 'unknown', + ); }); }); diff --git a/src/utils/fileAttachmentCorrelation.ts b/src/utils/fileAttachmentCorrelation.ts index abe71bd..1a5981c 100644 --- a/src/utils/fileAttachmentCorrelation.ts +++ b/src/utils/fileAttachmentCorrelation.ts @@ -1,10 +1,10 @@ import { LogEngine } from '@wgtechlabs/log-engine'; -import { - UnthreadWebhookEvent, - FileAttachmentCorrelationEntry, +import { FileAttachmentBufferedEvent, FileAttachmentBufferedEvents, - PlatformSource + FileAttachmentCorrelationEntry, + PlatformSource, + UnthreadWebhookEvent, } from '../types'; /** @@ -13,15 +13,15 @@ import { */ export class FileAttachmentCorrelationUtil { // File attachment correlation configuration - hardcoded for stability - private readonly FILE_ATTACHMENT_CORRELATION_TTL = 15000; // 15 seconds - private readonly FILE_ATTACHMENT_BUFFER_TIMEOUT = 10000; // 10 seconds + private readonly FILE_ATTACHMENT_CORRELATION_TTL = 15000; // 15 seconds + private readonly FILE_ATTACHMENT_BUFFER_TIMEOUT = 10000; // 10 seconds private readonly FILE_ATTACHMENT_CLEANUP_INTERVAL = 60000; // 1 minute // Memory storage for correlation private correlationCache = new Map(); private correlationTTL = new Map(); private bufferedEvents = new Map(); - + // Cleanup timer private cleanupTimer: NodeJS.Timeout | null = null; @@ -36,24 +36,25 @@ export class FileAttachmentCorrelationUtil { generateCorrelationKey(event: UnthreadWebhookEvent): string { const data = event.data; if (!data) return ''; - + // Collect only defined, non-empty values for correlation key const keyComponents = [ data.conversationId, data.threadTs, data.channelId, - data.teamId - ].filter(component => - component !== undefined && - component !== null && - String(component).trim() !== '' + data.teamId, + ].filter( + (component) => + component !== undefined && + component !== null && + String(component).trim() !== '', ); - + // Return empty string if we don't have enough components for reliable correlation if (keyComponents.length < 2) { return ''; } - + return keyComponents.join('-'); } @@ -77,42 +78,55 @@ export class FileAttachmentCorrelationUtil { */ cacheMessageEvent(event: UnthreadWebhookEvent, sourcePlatform: string): void { const correlationKey = this.generateCorrelationKey(event); - + // Abort caching if correlation key is insufficient/empty to prevent collisions if (!correlationKey || correlationKey.trim() === '') { - LogEngine.warn('Cannot cache message event - insufficient correlation data', { - messageEventId: event.eventId, - sourcePlatform, - eventData: { - conversationId: event.data?.conversationId, - threadTs: event.data?.threadTs, - channelId: event.data?.channelId, - teamId: event.data?.teamId - } - }); + LogEngine.warn( + 'Cannot cache message event - insufficient correlation data', + { + messageEventId: event.eventId, + sourcePlatform, + eventData: { + conversationId: event.data?.conversationId, + threadTs: event.data?.threadTs, + channelId: event.data?.channelId, + teamId: event.data?.teamId, + }, + }, + ); return; // Abort caching to prevent empty key collisions } - + const entry: FileAttachmentCorrelationEntry = { sourcePlatform, messageEventId: event.eventId, - timestamp: event.eventTimestamp + timestamp: event.eventTimestamp, }; - + this.correlationCache.set(correlationKey, entry); - this.correlationTTL.set(correlationKey, Date.now() + this.FILE_ATTACHMENT_CORRELATION_TTL); - + this.correlationTTL.set( + correlationKey, + Date.now() + this.FILE_ATTACHMENT_CORRELATION_TTL, + ); + LogEngine.debug('File attachment correlation cached', { correlationKey, sourcePlatform, messageEventId: event.eventId, - expiresAt: new Date(Date.now() + this.FILE_ATTACHMENT_CORRELATION_TTL).toISOString() + expiresAt: new Date( + Date.now() + this.FILE_ATTACHMENT_CORRELATION_TTL, + ).toISOString(), }); - + // Check if there's a buffered file event waiting for this correlation - this.processBufferedFileEvent(correlationKey, sourcePlatform).catch(error => { - LogEngine.error('Error processing buffered file event', { correlationKey, error }); - }); + this.processBufferedFileEvent(correlationKey, sourcePlatform).catch( + (error) => { + LogEngine.error('Error processing buffered file event', { + correlationKey, + error, + }); + }, + ); } /** @@ -121,24 +135,27 @@ export class FileAttachmentCorrelationUtil { */ correlateFileEvent(event: UnthreadWebhookEvent): PlatformSource { const correlationKey = this.generateCorrelationKey(event); - + // Abort correlation if key is insufficient/empty to prevent collisions if (!correlationKey || correlationKey.trim() === '') { - LogEngine.warn('Cannot correlate file attachment event - insufficient correlation data', { - fileEventId: event.eventId, - eventData: { - conversationId: event.data?.conversationId, - threadTs: event.data?.threadTs, - channelId: event.data?.channelId, - teamId: event.data?.teamId + LogEngine.warn( + 'Cannot correlate file attachment event - insufficient correlation data', + { + fileEventId: event.eventId, + eventData: { + conversationId: event.data?.conversationId, + threadTs: event.data?.threadTs, + channelId: event.data?.channelId, + teamId: event.data?.teamId, + }, + fallbackTo: 'unknown', }, - fallbackTo: 'unknown' - }); + ); return 'unknown'; // Fall back to unknown instead of attempting correlation } - + const correlationData = this.getCorrelationData(correlationKey); - + if (correlationData) { // Found correlation - return the source platform LogEngine.info('File attachment correlated immediately', { @@ -146,11 +163,11 @@ export class FileAttachmentCorrelationUtil { correlationKey, sourcePlatform: correlationData.sourcePlatform, messageEventId: correlationData.messageEventId, - timeSinceMessage: event.eventTimestamp - correlationData.timestamp + timeSinceMessage: event.eventTimestamp - correlationData.timestamp, }); return correlationData.sourcePlatform; } - + // No correlation found - buffer the file event this.bufferFileEvent(event, correlationKey); return 'buffered'; @@ -159,128 +176,151 @@ export class FileAttachmentCorrelationUtil { /** * Get correlation data from cache (with TTL check) */ - private getCorrelationData(correlationKey: string): FileAttachmentCorrelationEntry | null { + private getCorrelationData( + correlationKey: string, + ): FileAttachmentCorrelationEntry | null { // Safety check: return null for empty keys to prevent false lookups if (!correlationKey || correlationKey.trim() === '') { return null; } - + const expiration = this.correlationTTL.get(correlationKey); - + if (expiration && Date.now() > expiration) { // Expired - clean up this.correlationCache.delete(correlationKey); this.correlationTTL.delete(correlationKey); return null; } - + return this.correlationCache.get(correlationKey) || null; } /** * Buffer file event for delayed processing */ - private bufferFileEvent(event: UnthreadWebhookEvent, correlationKey: string): void { + private bufferFileEvent( + event: UnthreadWebhookEvent, + correlationKey: string, + ): void { // Safety check: abort buffering if correlation key is empty to prevent collisions if (!correlationKey || correlationKey.trim() === '') { - LogEngine.error('Cannot buffer file event - empty correlation key would cause collisions', { - fileEventId: event.eventId, - eventData: { - conversationId: event.data?.conversationId, - threadTs: event.data?.threadTs, - channelId: event.data?.channelId, - teamId: event.data?.teamId - } - }); + LogEngine.error( + 'Cannot buffer file event - empty correlation key would cause collisions', + { + fileEventId: event.eventId, + eventData: { + conversationId: event.data?.conversationId, + threadTs: event.data?.threadTs, + channelId: event.data?.channelId, + teamId: event.data?.teamId, + }, + }, + ); return; // Abort buffering to prevent empty key collisions } - + // Get existing buffered events for this key const existing = this.bufferedEvents.get(correlationKey); - + if (existing) { // Clear existing timeout clearTimeout(existing.sharedTimeoutId); - + // Check for duplicate events (same eventId) - const isDuplicate = existing.events.some(e => e.eventData.eventId === event.eventId); - + const isDuplicate = existing.events.some( + (e) => e.eventData.eventId === event.eventId, + ); + if (isDuplicate) { - LogEngine.warn('Duplicate file attachment event detected, skipping buffer', { - eventId: event.eventId, - correlationKey, - existingEventCount: existing.events.length - }); - + LogEngine.warn( + 'Duplicate file attachment event detected, skipping buffer', + { + eventId: event.eventId, + correlationKey, + existingEventCount: existing.events.length, + }, + ); + // Restore the timeout and return without adding duplicate const timeoutId = setTimeout(() => { - this.processBufferedEventsAsUnknown(correlationKey).catch(error => { - LogEngine.error('Error processing buffered events as unknown', { correlationKey, error }); + this.processBufferedEventsAsUnknown(correlationKey).catch((error) => { + LogEngine.error('Error processing buffered events as unknown', { + correlationKey, + error, + }); }); }, this.FILE_ATTACHMENT_BUFFER_TIMEOUT); existing.sharedTimeoutId = timeoutId; return; } - + // Add new event to existing buffer const bufferedEvent: FileAttachmentBufferedEvent = { eventData: event, correlationKey, bufferedAt: Date.now(), - timeoutId: null // Will use shared timeout + timeoutId: null, // Will use shared timeout }; - + existing.events.push(bufferedEvent); - + LogEngine.info('Added file attachment event to existing buffer', { eventId: event.eventId, correlationKey, totalBufferedEvents: existing.events.length, - bufferTimeout: this.FILE_ATTACHMENT_BUFFER_TIMEOUT + bufferTimeout: this.FILE_ATTACHMENT_BUFFER_TIMEOUT, }); - + // Clear existing timeout before creating new one if (existing.sharedTimeoutId) { clearTimeout(existing.sharedTimeoutId); } - + // Create new shared timeout for the updated buffer const timeoutId = setTimeout(() => { - this.processBufferedEventsAsUnknown(correlationKey).catch(error => { - LogEngine.error('Error processing buffered events as unknown', { correlationKey, error }); + this.processBufferedEventsAsUnknown(correlationKey).catch((error) => { + LogEngine.error('Error processing buffered events as unknown', { + correlationKey, + error, + }); }); }, this.FILE_ATTACHMENT_BUFFER_TIMEOUT); existing.sharedTimeoutId = timeoutId; - } else { // Create new buffer with first event const bufferedEvent: FileAttachmentBufferedEvent = { eventData: event, correlationKey, bufferedAt: Date.now(), - timeoutId: null // Will use shared timeout + timeoutId: null, // Will use shared timeout }; - + // Create timeout for fallback processing const timeoutId = setTimeout(() => { - this.processBufferedEventsAsUnknown(correlationKey).catch(error => { - LogEngine.error('Error processing buffered events as unknown', { correlationKey, error }); + this.processBufferedEventsAsUnknown(correlationKey).catch((error) => { + LogEngine.error('Error processing buffered events as unknown', { + correlationKey, + error, + }); }); }, this.FILE_ATTACHMENT_BUFFER_TIMEOUT); - + // Store buffered events const bufferedEvents: FileAttachmentBufferedEvents = { events: [bufferedEvent], - sharedTimeoutId: timeoutId + sharedTimeoutId: timeoutId, }; - + this.bufferedEvents.set(correlationKey, bufferedEvents); - + LogEngine.info('File attachment event buffered for correlation', { eventId: event.eventId, correlationKey, bufferTimeout: this.FILE_ATTACHMENT_BUFFER_TIMEOUT, - willExpireAt: new Date(Date.now() + this.FILE_ATTACHMENT_BUFFER_TIMEOUT).toISOString() + willExpireAt: new Date( + Date.now() + this.FILE_ATTACHMENT_BUFFER_TIMEOUT, + ).toISOString(), }); } } @@ -288,42 +328,54 @@ export class FileAttachmentCorrelationUtil { /** * Process buffered file events when correlation becomes available */ - private async processBufferedFileEvent(correlationKey: string, sourcePlatform: string): Promise { + private async processBufferedFileEvent( + correlationKey: string, + sourcePlatform: string, + ): Promise { // Safety check: skip processing if correlation key is empty to prevent wrong event processing if (!correlationKey || correlationKey.trim() === '') { - LogEngine.warn('Skipping buffered event processing - empty correlation key', { - sourcePlatform - }); + LogEngine.warn( + 'Skipping buffered event processing - empty correlation key', + { + sourcePlatform, + }, + ); return; } - + const bufferedEvents = this.bufferedEvents.get(correlationKey); - + if (bufferedEvents) { // Cancel shared timeout clearTimeout(bufferedEvents.sharedTimeoutId); - + // Remove from buffer this.bufferedEvents.delete(correlationKey); - + LogEngine.info('Processing buffered file attachments with correlation', { correlationKey, sourcePlatform, eventCount: bufferedEvents.events.length, - eventIds: bufferedEvents.events.map(e => e.eventData.eventId) + eventIds: bufferedEvents.events.map((e) => e.eventData.eventId), }); - + // Process each buffered event sequentially let index = 0; for (const bufferedEvent of bufferedEvents.events) { index++; - LogEngine.debug(`Processing buffered event ${index}/${bufferedEvents.events.length}`, { - eventId: bufferedEvent.eventData.eventId, - bufferedFor: Date.now() - bufferedEvent.bufferedAt - }); - + LogEngine.debug( + `Processing buffered event ${index}/${bufferedEvents.events.length}`, + { + eventId: bufferedEvent.eventData.eventId, + bufferedFor: Date.now() - bufferedEvent.bufferedAt, + }, + ); + // Trigger callback to continue processing and await completion - await this.onBufferedEventReady?.(bufferedEvent.eventData, sourcePlatform); + await this.onBufferedEventReady?.( + bufferedEvent.eventData, + sourcePlatform, + ); } } } @@ -331,34 +383,42 @@ export class FileAttachmentCorrelationUtil { /** * Process buffered events as unknown when timeout expires */ - private async processBufferedEventsAsUnknown(correlationKey: string): Promise { + private async processBufferedEventsAsUnknown( + correlationKey: string, + ): Promise { // Safety check: skip processing if correlation key is empty if (!correlationKey || correlationKey.trim() === '') { LogEngine.warn('Skipping timeout processing - empty correlation key'); return; } - + const bufferedEvents = this.bufferedEvents.get(correlationKey); - + if (bufferedEvents) { this.bufferedEvents.delete(correlationKey); - - LogEngine.warn('File attachment events timed out waiting for correlation', { - correlationKey, - eventCount: bufferedEvents.events.length, - eventIds: bufferedEvents.events.map(e => e.eventData.eventId), - processedAs: 'unknown' - }); - + + LogEngine.warn( + 'File attachment events timed out waiting for correlation', + { + correlationKey, + eventCount: bufferedEvents.events.length, + eventIds: bufferedEvents.events.map((e) => e.eventData.eventId), + processedAs: 'unknown', + }, + ); + // Process each buffered event as unknown sequentially let index = 0; for (const bufferedEvent of bufferedEvents.events) { index++; - LogEngine.debug(`Processing timed-out event ${index}/${bufferedEvents.events.length} as unknown`, { - eventId: bufferedEvent.eventData.eventId, - bufferedFor: Date.now() - bufferedEvent.bufferedAt - }); - + LogEngine.debug( + `Processing timed-out event ${index}/${bufferedEvents.events.length} as unknown`, + { + eventId: bufferedEvent.eventData.eventId, + bufferedFor: Date.now() - bufferedEvent.bufferedAt, + }, + ); + // Process as unknown and await completion await this.onBufferedEventReady?.(bufferedEvent.eventData, 'unknown'); } @@ -380,7 +440,7 @@ export class FileAttachmentCorrelationUtil { private cleanup(): void { const now = Date.now(); let cleanedCount = 0; - + // Cleanup correlation cache for (const [key, expiration] of this.correlationTTL.entries()) { if (now > expiration) { @@ -389,14 +449,15 @@ export class FileAttachmentCorrelationUtil { cleanedCount++; } } - + // Cleanup any stale buffered events (shouldn't happen but safety check) for (const [key, bufferedEvents] of this.bufferedEvents.entries()) { // Check if any event in the buffer is too old - const hasStaleEvents = bufferedEvents.events.some(event => - now - event.bufferedAt > this.FILE_ATTACHMENT_BUFFER_TIMEOUT + 5000 + const hasStaleEvents = bufferedEvents.events.some( + (event) => + now - event.bufferedAt > this.FILE_ATTACHMENT_BUFFER_TIMEOUT + 5000, ); - + if (hasStaleEvents) { clearTimeout(bufferedEvents.sharedTimeoutId); this.bufferedEvents.delete(key); @@ -404,16 +465,16 @@ export class FileAttachmentCorrelationUtil { LogEngine.warn('Cleaned up stale buffered file attachment events', { correlationKey: key, eventCount: bufferedEvents.events.length, - eventIds: bufferedEvents.events.map(e => e.eventData.eventId) + eventIds: bufferedEvents.events.map((e) => e.eventData.eventId), }); } } - + if (cleanedCount > 0) { LogEngine.debug('File attachment correlation cleanup completed', { cleanedEntries: cleanedCount, activeCorrelations: this.correlationCache.size, - bufferedEvents: this.bufferedEvents.size + bufferedEvents: this.bufferedEvents.size, }); } } @@ -427,7 +488,7 @@ export class FileAttachmentCorrelationUtil { } { return { activeCorrelations: this.correlationCache.size, - bufferedEvents: this.bufferedEvents.size + bufferedEvents: this.bufferedEvents.size, }; } @@ -435,7 +496,10 @@ export class FileAttachmentCorrelationUtil { * Callback for when buffered event is ready to process * This will be set by WebhookService */ - onBufferedEventReady?: (event: UnthreadWebhookEvent, sourcePlatform: string) => void | Promise; + onBufferedEventReady?: ( + event: UnthreadWebhookEvent, + sourcePlatform: PlatformSource, + ) => void | Promise; /** * Cleanup resources @@ -445,12 +509,12 @@ export class FileAttachmentCorrelationUtil { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } - + // Clear all timeouts for (const bufferedEvents of this.bufferedEvents.values()) { clearTimeout(bufferedEvents.sharedTimeoutId); } - + // Clear all data this.correlationCache.clear(); this.correlationTTL.clear(); diff --git a/src/utils/signature.test.ts b/src/utils/signature.test.ts index dbeeb0f..7196d2a 100644 --- a/src/utils/signature.test.ts +++ b/src/utils/signature.test.ts @@ -1,32 +1,61 @@ -import { createHmac } from 'crypto'; import { describe, expect, it } from 'bun:test'; -import { generateSignature, verifySignature, verifyUnthreadSignature, verifyWebhookSignature } from './signature'; +import { createHmac } from 'crypto'; +import { + generateSignature, + verifySignature, + verifyUnthreadSignature, + verifyWebhookSignature, +} from './signature'; describe('signature utils', () => { it('verifies unthread signature with timing-safe comparison', () => { const rawBody = JSON.stringify({ hello: 'world' }); const secret = 'secret'; - const signature = createHmac('sha256', secret).update(rawBody).digest('hex'); + const signature = createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); const req = { headers: { 'x-unthread-signature': signature }, - rawBody + rawBody, }; expect(verifyUnthreadSignature(req as any, secret)).toBe(true); - expect(verifyUnthreadSignature({ ...req, headers: { 'x-unthread-signature': 'zzzz' } } as any, secret)).toBe(false); - expect(verifyUnthreadSignature({ ...req, headers: { 'x-unthread-signature': signature.slice(2) } } as any, secret)).toBe(false); + expect( + verifyUnthreadSignature( + { ...req, headers: { 'x-unthread-signature': 'zzzz' } } as any, + secret, + ), + ).toBe(false); + expect( + verifyUnthreadSignature( + { + ...req, + headers: { 'x-unthread-signature': signature.slice(2) }, + } as any, + secret, + ), + ).toBe(false); }); it('verifies legacy webhook signature with timing-safe comparison', () => { const secret = 'legacy-secret'; const body = { event: 'test' }; - const payload = JSON.stringify(body); - const signature = createHmac('sha256', secret).update(payload).digest('hex'); - const req = { headers: { 'x-signature': signature }, body }; + const rawBody = JSON.stringify(body); + const signature = createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + const req = { headers: { 'x-signature': signature }, rawBody }; expect(verifyWebhookSignature(req as any, secret)).toBe(true); - expect(verifyWebhookSignature({ ...req, headers: {} } as any, secret)).toBe(false); - expect(verifyWebhookSignature({ ...req, headers: { 'x-signature': 'not-hex' } } as any, secret)).toBe(false); + expect(verifyWebhookSignature({ ...req, headers: {} } as any, secret)).toBe( + false, + ); + expect( + verifyWebhookSignature( + { ...req, headers: { 'x-signature': 'not-hex' } } as any, + secret, + ), + ).toBe(false); }); it('generates and verifies sha256-prefixed signatures', () => { diff --git a/src/utils/signature.ts b/src/utils/signature.ts index bc5d1b1..b9d794d 100644 --- a/src/utils/signature.ts +++ b/src/utils/signature.ts @@ -2,81 +2,84 @@ import { createHmac, timingSafeEqual } from 'crypto'; import { Request } from 'express'; interface WebhookRequest extends Request { - rawBody: string; + rawBody: string; } +/** + * Timing-safe comparison of two hex-encoded HMAC signatures. + * Returns false if either value is not valid hex or lengths differ. + */ +const compareHexSignatures = (provided: string, expected: string): boolean => { + try { + if (!/^[a-fA-F0-9]+$/.test(provided) || provided.length % 2 !== 0) { + return false; + } + const providedBuf = Buffer.from(provided, 'hex'); + const expectedBuf = Buffer.from(expected, 'hex'); + if (providedBuf.length !== expectedBuf.length) { + return false; + } + return timingSafeEqual(providedBuf, expectedBuf); + } catch { + return false; + } +}; + /** * Verify Unthread.io webhook signature * @param req - Express request object with rawBody property * @param signingSecret - Unthread signing secret * @returns True if signature is valid */ -export const verifyUnthreadSignature = (req: WebhookRequest, signingSecret: string): boolean => { - const signature = req.headers['x-unthread-signature'] as string; - - if (!signature) { - return false; - } - - // Use raw body for signature verification as recommended by Unthread - const rawBody = req.rawBody; - - if (!rawBody) { - return false; - } - - const expectedSignature = createHmac('sha256', signingSecret) - .update(rawBody) - .digest('hex'); - - try { - if (!/^[a-fA-F0-9]+$/.test(signature) || signature.length % 2 !== 0) { - return false; - } - const provided = Buffer.from(signature, 'hex'); - const expected = Buffer.from(expectedSignature, 'hex'); - - if (provided.length !== expected.length) { - return false; - } - - return timingSafeEqual(provided, expected); - } catch { - return false; - } +export const verifyUnthreadSignature = ( + req: WebhookRequest, + signingSecret: string, +): boolean => { + const signature = req.headers['x-unthread-signature'] as string; + + if (!signature) { + return false; + } + + // Use raw body for signature verification as recommended by Unthread + const rawBody = req.rawBody; + + if (!rawBody) { + return false; + } + + const expectedSignature = createHmac('sha256', signingSecret) + .update(rawBody) + .digest('hex'); + + return compareHexSignatures(signature, expectedSignature); }; /** * Legacy function for backward compatibility - * @param req - Express request object + * @param req - Express request object with rawBody property * @param secret - Webhook secret * @returns True if signature is valid */ -export const verifyWebhookSignature = (req: Request, secret: string): boolean => { - const signature = req.headers['x-signature'] as string; - if (!signature) { - return false; - } - const payload = JSON.stringify(req.body); - const expectedSignature = createHmac('sha256', secret) - .update(payload) - .digest('hex'); - - try { - if (!/^[a-fA-F0-9]+$/.test(signature) || signature.length % 2 !== 0) { - return false; - } - const provided = Buffer.from(signature, 'hex'); - const expected = Buffer.from(expectedSignature, 'hex'); - - if (provided.length !== expected.length) { - return false; - } - - return timingSafeEqual(provided, expected); - } catch { - return false; - } +export const verifyWebhookSignature = ( + req: WebhookRequest, + secret: string, +): boolean => { + const signature = req.headers['x-signature'] as string; + if (!signature) { + return false; + } + + const rawBody = req.rawBody; + if (!rawBody) { + return false; + } + + const expectedSignature = createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + return compareHexSignatures(signature, expectedSignature); }; /** @@ -86,11 +89,11 @@ export const verifyWebhookSignature = (req: Request, secret: string): boolean => * @returns Signature in sha256= format */ export const generateSignature = (payload: string, secret: string): string => { - const hash = createHmac('sha256', secret) - .update(payload, 'utf8') - .digest('hex'); - - return `sha256=${hash}`; + const hash = createHmac('sha256', secret) + .update(payload, 'utf8') + .digest('hex'); + + return `sha256=${hash}`; }; /** @@ -100,28 +103,32 @@ export const generateSignature = (payload: string, secret: string): string => { * @param secret - The signing secret * @returns True if signature is valid */ -export const verifySignature = (payload: string, signature: string, secret: string): boolean => { - if (!signature || typeof signature !== 'string') { - return false; - } - - if (!payload || typeof payload !== 'string') { - return false; - } - - const expectedSignature = generateSignature(payload, secret); - - try { - // Ensure both strings are the same length before comparison - if (signature.length !== expectedSignature.length) { - return false; - } - - return timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature) - ); - } catch { - return false; +export const verifySignature = ( + payload: string, + signature: string, + secret: string, +): boolean => { + if (!signature || typeof signature !== 'string') { + return false; + } + + if (!payload || typeof payload !== 'string') { + return false; + } + + const expectedSignature = generateSignature(payload, secret); + + try { + // Ensure both strings are the same length before comparison + if (signature.length !== expectedSignature.length) { + return false; } + + return timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ); + } catch { + return false; + } }; diff --git a/tsconfig.json b/tsconfig.json index 15c4a3f..74bb7d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,10 +25,7 @@ "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true }, - "include": [ - "src/**/*", - "src/types/**/*" - ], + "include": ["src/**/*", "src/types/**/*"], "exclude": [ "node_modules", "dist",