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
|
+| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
|
-| UnthreadStreamlined support ticketing for modern teams.
|
+| UnthreadStreamlined 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",