Browse Source

feat: captcha

* Added Revis distributed cash to enhance our Captcha Verification system so that we prevent our system from replay attacks

* Fix: There was an error with the implementation of Redis, so I reverted to our previous version that uses in memory storage

* Integrated the captcha verification system into our sign in Form. The captcha verification system now works on both login and sign int

* Remove test files from captcha module

* Update src/backend/src/modules/captcha/middleware/captcha-middleware.js

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>

* Update src/backend/src/modules/captcha/middleware/captcha-middleware.js

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>

* Now the captcha can be requested on condition, this llaows extenstions to control wether a captcha should be required,
I fixed the code in CaptchaModule to use config
and got rid of the lines that made captcha middleware available since it wasn't used anywhre

* I split the middleware into two distinct parts, so that the frontend can now determine captach requirements. PuterHomePageService can set GUI parameters for captcha requirements. The /whoarewe endpoint provides captcha requirement information and the extensuo system integration is maintained

* Fix security issues with password handling in URL query parameters

* Made sure that the enter key, submits the login request instead of refreshing the captcha

* In development we can now disable the Captcha verification system by running it with CAPTCHA_ENABLED=false npm start

* Went back and modified checkCaptcha so that it checks at the start to check what CAPTCHA_ENABLED is equal to

* Refactor captcha system to use configuration values instead of environment variables

* Fix captcha verification and align with project standards

* Update src/backend/src/modules/captcha/README.md

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>

* fix: incorrect service name

* dev: use Endpoint for captcha endpoints

Use Endpoint class, which uses eggspress behind the scenes, which handles
async errors in handlers automatically.

* dev: add extension support and simplify captcha

- removed extra error handling
- removed dormant code
- no distinction between login and signup (for now)

* clean: remove local files

* fix: undefined edge case

---------

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>
Jonathan Mahrt Guyou 1 month ago
parent
commit
ad4b3e7aeb

+ 1 - 1
.gitignore

@@ -29,4 +29,4 @@ dist/
 
 # Local Netlify folder
 .netlify
-src/emulator/release/
+src/emulator/release/

File diff suppressed because it is too large
+ 1467 - 1197
package-lock.json


+ 1 - 0
package.json

@@ -49,6 +49,7 @@
   "dependencies": {
     "@heyputer/putility": "^1.0.2",
     "dedent": "^1.5.3",
+    "ioredis": "^5.6.0",
     "javascript-time-ago": "^2.5.11",
     "json-colorizer": "^3.0.1",
     "open": "^10.1.0",

+ 3 - 0
src/backend/exports.js

@@ -39,6 +39,7 @@ const { InternetModule } = require("./src/modules/internet/InternetModule.js");
 const { PuterExecModule } = require("./src/modules/puterexec/PuterExecModule.js");
 const { MailModule } = require("./src/modules/mail/MailModule.js");
 const { ConvertModule } = require("./src/modules/convert/ConvertModule.js");
+const { CaptchaModule } = require("./src/modules/captcha/CaptchaModule.js");
 
 module.exports = {
     helloworld: () => {
@@ -61,6 +62,7 @@ module.exports = {
         WebModule,
         TemplateModule,
         AppsModule,
+        CaptchaModule,
     ],
 
     // Pre-built modules
@@ -76,6 +78,7 @@ module.exports = {
     InternetModule,
     MailModule,
     ConvertModule,
+    CaptchaModule,
     
     // Development modules
     PerfMonModule,

+ 2 - 1
src/backend/package.json

@@ -24,7 +24,7 @@
     "@smithy/node-http-handler": "^2.2.2",
     "args": "^5.0.3",
     "aws-sdk": "^2.1383.0",
-    "axios": "^1.4.0",
+    "axios": "^1.8.2",
     "bcrypt": "^5.1.0",
     "better-sqlite3": "^11.9.0",
     "busboy": "^1.6.0",
@@ -73,6 +73,7 @@
     "ssh2": "^1.13.0",
     "string-hash": "^1.1.3",
     "string-length": "^6.0.0",
+    "svg-captcha": "^1.4.0",
     "svgo": "^3.0.2",
     "tiktoken": "^1.0.16",
     "together-ai": "^0.6.0-alpha.4",

+ 11 - 1
src/backend/src/api/APIError.js

@@ -483,7 +483,17 @@ module.exports = class APIError {
         'not_yet_supported': {
             status: 400,
             message: ({ message }) => message,
-        }
+        },
+
+        // Captcha errors
+        'captcha_required': {
+            status: 400,
+            message: ({ message }) => message || 'Captcha verification required',
+        },
+        'captcha_invalid': {
+            status: 400,
+            message: ({ message }) => message || 'Invalid captcha response',
+        },
     };
 
     /**

+ 7 - 0
src/backend/src/config.js

@@ -51,6 +51,13 @@ config.require_email_verification_to_publish_website = false;
 config.kv_max_key_size = 1024;
 config.kv_max_value_size = 400 * 1024;
 
+// Captcha configuration
+config.captcha = {
+    enabled: false,                 // Enable captcha by default
+    expirationTime: 10 * 60 * 1000, // 10 minutes default expiration time
+    difficulty: 'medium'            // Default difficulty level
+};
+
 config.monitor = {
     metricsInterval: 60000,
     windowSize: 30,

+ 2 - 0
src/backend/src/index.js

@@ -20,10 +20,12 @@
 
 const { Kernel } = require("./Kernel");
 const CoreModule = require("./CoreModule");
+const { CaptchaModule } = require("./modules/captcha/CaptchaModule"); // Add CaptchaModule
 
 const testlaunch = () => {
     const k = new Kernel();
     k.add_module(new CoreModule());
+    k.add_module(new CaptchaModule()); // Register the CaptchaModule
     k.boot();
 }
 

+ 58 - 0
src/backend/src/modules/captcha/CaptchaModule.js

@@ -0,0 +1,58 @@
+// METADATA // {"ai-commented":{"service":"claude"}}
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const { AdvancedBase } = require("@heyputer/putility");
+const CaptchaService = require('./services/CaptchaService');
+
+/**
+ * @class CaptchaModule
+ * @extends AdvancedBase
+ * @description Module that provides captcha verification functionality to protect
+ * against automated abuse, particularly for login and signup flows. Registers
+ * a CaptchaService for generating and verifying captchas as well as middlewares
+ * that can be used to protect routes and determine captcha requirements.
+ */
+class CaptchaModule extends AdvancedBase {
+    async install(context) {
+        console.log('DIAGNOSTIC: CaptchaModule.install - Start of method');
+        
+        // Get services from context
+        const services = context.get('services');
+        if (!services) {
+            throw new Error('Services not available in context');
+        }
+        
+        // Register the captcha service
+        console.log('DIAGNOSTIC: CaptchaModule.install - Before service registration');
+        services.registerService('captcha', CaptchaService);
+        console.log('DIAGNOSTIC: CaptchaModule.install - After service registration');
+        
+        // Log the captcha service status
+        try {
+            const captchaService = services.get('captcha');
+            console.log(`Captcha service registered and ${captchaService.enabled ? 'enabled' : 'disabled'}`);
+            console.log('TOKENS_TRACKING: Retrieved CaptchaService instance with ID:', captchaService.serviceId);
+        } catch (error) {
+            console.error('Failed to get captcha service after registration:', error);
+        }
+    }
+}
+
+module.exports = { CaptchaModule }; 

+ 73 - 0
src/backend/src/modules/captcha/README.md

@@ -0,0 +1,73 @@
+# Captcha Module
+
+This module provides captcha verification functionality to protect against automated abuse, particularly for login and signup flows.
+
+## Components
+
+- **CaptchaModule.js**: Registers the service and middleware
+- **CaptchaService.js**: Provides captcha generation and verification functionality
+- **captcha-middleware.js**: Express middleware for protecting routes with captcha verification
+
+## Integration
+
+The CaptchaService is registered by the CaptchaModule and can be accessed by other services:
+
+```javascript
+const captchaService = services.get('captcha');
+```
+
+### Example Usage
+
+```javascript
+// Generate a captcha
+const captcha = captchaService.generateCaptcha();
+// captcha.token - The token to verify later
+// captcha.image - SVG image data to display to the user
+
+// Verify a captcha
+const isValid = captchaService.verifyCaptcha(token, userAnswer);
+```
+
+## Configuration
+
+The CaptchaService can be configured with the following options in the configuration file (`config.json`):
+
+- `captcha.enabled`: Whether the captcha service is enabled (default: false)
+- `captcha.expirationTime`: How long captcha tokens are valid in milliseconds (default: 10 minutes)
+- `captcha.difficulty`: The difficulty level of the captcha ('easy', 'medium', 'hard') (default: 'medium')
+
+These options are set in the main configuration file. For example:
+
+```json
+{
+  "services": {
+    "captcha": {
+      "enabled": false,
+      "expirationTime": 600000,
+      "difficulty": "medium"
+    }
+  }
+}
+```
+
+### Development Configuration
+
+For local development, you can disable captcha by creating or modifying your local configuration file (e.g., in `volatile/config/config.json` or using a profile configuration):
+
+```json
+{
+  "$version": "v1.1.0",
+  "$requires": [
+    "config.json"
+  ],
+  "config_name": "local",
+  
+  "services": {
+    "captcha": {
+      "enabled": false
+    }
+  }
+}
+```
+
+These options are set when registering the service in CaptchaModule.js. 

+ 160 - 0
src/backend/src/modules/captcha/middleware/README.md

@@ -0,0 +1,160 @@
+# Captcha Middleware
+
+This middleware provides captcha verification for routes that need protection against automated abuse.
+
+## Middleware Components
+
+The captcha system is now split into two middleware components:
+
+1. **checkCaptcha**: Determines if captcha verification is required but doesn't perform verification.
+2. **requireCaptcha**: Performs actual captcha verification based on the result from checkCaptcha.
+
+This split allows frontend applications to know in advance whether captcha verification will be needed for a particular action.
+
+## Usage Patterns
+
+### Using Both Middlewares (Recommended)
+
+For best user experience, use both middlewares together:
+
+```javascript
+const express = require('express');
+const router = express.Router();
+
+// Get both middleware components from the context
+const { checkCaptcha, requireCaptcha } = context.get('captcha-middleware');
+
+// Determine if captcha is required for this route
+router.post('/login', checkCaptcha({ eventType: 'login' }), (req, res, next) => {
+  // Set a flag in the response so frontend knows if captcha is needed
+  res.locals.captchaRequired = req.captchaRequired;
+  next();
+}, requireCaptcha(), (req, res) => {
+  // Handle login logic
+  // If captcha was required, it has been verified at this point
+});
+```
+
+### Using Individual Middlewares
+
+You can also access each middleware separately:
+
+```javascript
+const checkCaptcha = context.get('check-captcha-middleware');
+const requireCaptcha = context.get('require-captcha-middleware');
+```
+
+### Using Only requireCaptcha (Legacy Mode)
+
+For backward compatibility, you can still use only the requireCaptcha middleware:
+
+```javascript
+const requireCaptcha = context.get('require-captcha-middleware');
+
+// Always require captcha for this route
+router.post('/sensitive-route', requireCaptcha({ always: true }), (req, res) => {
+  // Route handler
+});
+
+// Conditionally require captcha based on extensions
+router.post('/normal-route', requireCaptcha(), (req, res) => {
+  // Route handler
+});
+```
+
+## Configuration Options
+
+### checkCaptcha Options
+
+- `always` (boolean): Always require captcha regardless of other factors
+- `strictMode` (boolean): If true, fails closed on errors (more secure)
+- `eventType` (string): Type of event for extensions (e.g., 'login', 'signup')
+
+### requireCaptcha Options
+
+- `strictMode` (boolean): If true, fails closed on errors (more secure)
+
+## Frontend Integration
+
+There are two ways to integrate with the frontend:
+
+### 1. Using the checkCaptcha Result in API Responses
+
+You can include the captcha requirement in API responses:
+
+```javascript
+router.get('/whoarewe', checkCaptcha({ eventType: 'login' }), (req, res) => {
+  res.json({
+    // Other environment information
+    captchaRequired: {
+      login: req.captchaRequired
+    }
+  });
+});
+```
+
+### 2. Setting GUI Parameters
+
+For PuterHomepageService, you can add captcha requirements to GUI parameters:
+
+```javascript
+// In PuterHomepageService.js
+gui_params: {
+  // Other parameters
+  captchaRequired: {
+    login: req.captchaRequired
+  }
+}
+```
+
+## Client-Side Integration
+
+To integrate with the captcha middleware, the client needs to:
+
+1. Check if captcha is required for the action (using /whoarewe or GUI parameters)
+2. If required, call the `/api/captcha/generate` endpoint to get a captcha token and image
+3. Display the captcha image to the user and collect their answer
+4. Include the captcha token and answer in the request body:
+
+```javascript
+// Example client-side code
+async function submitWithCaptcha(formData) {
+  // Check if captcha is required
+  const envInfo = await fetch('/api/whoarewe').then(r => r.json());
+  
+  if (envInfo.captchaRequired?.login) {
+    // Get and display captcha to user
+    const captcha = await getCaptchaFromServer();
+    showCaptchaToUser(captcha);
+    
+    // Add captcha token and answer to the form data
+    formData.captchaToken = captcha.token;
+    formData.captchaAnswer = await getUserCaptchaAnswer();
+  }
+  
+  // Submit the form
+  const response = await fetch('/api/login', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(formData)
+  });
+  
+  // Handle response
+  const data = await response.json();
+  if (response.status === 400 && data.error === 'captcha_required') {
+    // Show captcha to the user if not already shown
+    showCaptcha();
+  }
+}
+```
+
+## Error Handling
+
+The middleware will throw the following errors:
+
+- `captcha_required`: When captcha verification is required but no token or answer was provided.
+- `captcha_invalid`: When the provided captcha answer is incorrect.
+
+These errors can be caught by the API error handler and returned to the client. 

+ 134 - 0
src/backend/src/modules/captcha/middleware/captcha-middleware.js

@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const APIError = require("../../../api/APIError");
+const { Context } = require("../../../util/context");
+
+/**
+ * Middleware that checks if captcha verification is required
+ * This is the "first half" of the captcha verification process
+ * It determines if verification is needed but doesn't perform verification
+ * 
+ * @param {Object} options - Configuration options
+ * @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
+ * @returns {Function} Express middleware function
+ */
+const checkCaptcha = ({ svc_captcha }) => async (req, res, next) => {
+    // Get services from the Context
+    const services = Context.get('services');
+    
+    if ( ! svc_captcha.enabled ) {
+        req.captchaRequired = false;
+        return next();
+    }
+
+    const svc_event = services.get('event');
+    const event = {
+        // By default, captcha always appears if enabled
+        required: true,
+    };
+    await svc_event.emit('captcha.check', event);
+    
+    // Set captcha requirement based on service status
+    req.captchaRequired = event.required;
+    next();
+};
+
+/**
+ * Middleware that requires captcha verification
+ * This is the "second half" of the captcha verification process
+ * It uses the result from checkCaptcha to determine if verification is needed
+ * 
+ * @param {Object} options - Configuration options
+ * @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
+ * @returns {Function} Express middleware function
+ */
+const requireCaptcha = (options = {}) => async (req, res, next) => {
+    if ( ! req.captchaRequired ) {
+        return next();
+    }
+    
+    const services = Context.get('services');
+    
+    try {
+        let captchaService;
+        try {
+            captchaService = services.get('captcha');
+        } catch (error) {
+            console.warn('Captcha verification: required service not available', error);
+            return next(APIError.create('internal_error', null, {
+                message: 'Captcha service unavailable',
+                status: 503
+            }));
+        }
+
+        // Fail closed if captcha service doesn't exist or isn't properly initialized
+        if (!captchaService || typeof captchaService.verifyCaptcha !== 'function') {
+            return next(APIError.create('internal_error', null, {
+                message: 'Captcha service misconfigured',
+                status: 500
+            }));
+        }
+        
+        // Check for captcha token and answer in request
+        const captchaToken = req.body.captchaToken;
+        const captchaAnswer = req.body.captchaAnswer;
+
+        if (!captchaToken || !captchaAnswer) {
+            return next(APIError.create('captcha_required', null, {
+                message: 'Captcha verification required',
+                status: 400
+            }));
+        }
+        
+        // Verify the captcha
+        let isValid;
+        try {
+            isValid = captchaService.verifyCaptcha(captchaToken, captchaAnswer);
+        } catch (verifyError) {
+            console.error('Captcha verification: threw an error', verifyError);
+            return next(APIError.create('captcha_invalid', null, {
+                message: 'Captcha verification failed',
+                status: 400
+            }));
+        }
+        
+        // Check verification result
+        if (!isValid) {
+            return next(APIError.create('captcha_invalid', null, {
+                message: 'Invalid captcha response',
+                status: 400
+            }));
+        }
+
+        // Captcha verified successfully, continue
+        next();
+    } catch (error) {
+        console.error('Captcha verification: unexpected error', error);
+        return next(APIError.create('internal_error', null, {
+            message: 'Captcha verification failed',
+            status: 500
+        }));
+    }
+};
+
+module.exports = {
+    checkCaptcha,
+    requireCaptcha
+}; 

+ 674 - 0
src/backend/src/modules/captcha/services/CaptchaService.js

@@ -0,0 +1,674 @@
+// METADATA // {"ai-commented":{"service":"claude"}}
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const BaseService = require('../../../services/BaseService');
+const { Endpoint } = require('../../../util/expressutil');
+const { checkCaptcha } = require('../middleware/captcha-middleware');
+
+/**
+ * @class CaptchaService
+ * @extends BaseService
+ * @description Service that provides captcha generation and verification functionality
+ * to protect against automated abuse. Uses svg-captcha for generation and maintains
+ * a token-based verification system.
+ */
+class CaptchaService extends BaseService {
+    /**
+     * Initializes the captcha service with configuration and storage
+     */
+    async _construct() {
+        console.log('DIAGNOSTIC: CaptchaService._construct called');
+        
+        // Load dependencies
+        this.crypto = require('crypto');
+        this.svgCaptcha = require('svg-captcha');
+        
+        // In-memory token storage with expiration
+        this.captchaTokens = new Map();
+        
+        // Service instance diagnostic tracking
+        this.serviceId = Math.random().toString(36).substring(2, 10);
+        this.requestCounter = 0;
+        
+        console.log('TOKENS_TRACKING: CaptchaService instance created with ID:', this.serviceId);
+        console.log('TOKENS_TRACKING: Process ID:', process.pid);
+        
+        // Get configuration from service config
+        this.enabled = this.config.enabled === true;
+        this.expirationTime = this.config.expirationTime || (10 * 60 * 1000); // 10 minutes default
+        this.difficulty = this.config.difficulty || 'medium';
+        this.testMode = this.config.testMode === true;
+        
+        console.log('CAPTCHA DIAGNOSTIC: Service initialized with config:', {
+            enabled: this.enabled,
+            expirationTime: this.expirationTime,
+            difficulty: this.difficulty,
+            testMode: this.testMode
+        });
+        
+        // Add a static test token for diagnostic purposes
+        this.captchaTokens.set('test-static-token', {
+            text: 'testanswer',
+            expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
+        });
+        
+        // Flag to track if endpoints are registered
+        this.endpointsRegistered = false;
+    }
+
+    async ['__on_install.middlewares.context-aware'] (_, { app }) {
+        // Add express middleware
+        app.use(checkCaptcha({ svc_captcha: this }));
+    }
+
+    /**
+     * Sets up API endpoints and cleanup tasks
+     */
+    async _init() {
+        console.log('TOKENS_TRACKING: CaptchaService._init called. Service ID:', this.serviceId);
+        
+        if (!this.enabled) {
+            this.log.info('Captcha service is disabled');
+            return;
+        }
+
+        // Set up periodic cleanup
+        this.cleanupInterval = setInterval(() => this.cleanupExpiredTokens(), 15 * 60 * 1000);
+        
+        // Register endpoints if not already done
+        if (!this.endpointsRegistered) {
+            this.registerEndpoints();
+            this.endpointsRegistered = true;
+        }
+    }
+
+    /**
+     * Cleanup method called when service is being destroyed
+     */
+    async _destroy() {
+        if (this.cleanupInterval) {
+            clearInterval(this.cleanupInterval);
+        }
+        this.captchaTokens.clear();
+    }
+
+    /**
+     * Registers the captcha API endpoints with the web service
+     * @private
+     */
+    registerEndpoints() {
+        if (this.endpointsRegistered) {
+            return;
+        }
+        
+        try {
+            // Try to get the web service
+            let webService = null;
+            try {
+                webService = this.services.get('web-service');
+            } catch (error) {
+                // Web service not available, try web-server
+                try {
+                    webService = this.services.get('web-server');
+                } catch (innerError) {
+                    this.log.warn('Neither web-service nor web-server are available yet');
+                    return;
+                }
+            }
+            
+            if (!webService || !webService.app) {
+                this.log.warn('Web service found but app is not available');
+                return;
+            }
+
+            const app = webService.app;
+            
+            const api = this.require('express').Router();
+            app.use('/api/captcha', api);
+            
+            // Generate captcha endpoint
+            Endpoint({
+                route: '/generate',
+                methods: ['GET'],
+                handler: async (req, res) => {
+                    const captcha = this.generateCaptcha();
+                    res.json({
+                        token: captcha.token,
+                        image: captcha.data
+                    });
+                },
+            }).attach(api);
+
+            // Verify captcha endpoint
+            Endpoint({
+                route: '/verify',
+                methods: ['POST'],
+                handler: (req, res) => {
+                    const { token, answer } = req.body;
+                    
+                    if (!token || !answer) {
+                        return res.status(400).json({ 
+                            valid: false, 
+                            error: 'Missing token or answer' 
+                        });
+                    }
+                    
+                    const isValid = this.verifyCaptcha(token, answer);
+                    res.json({ valid: isValid });
+                },
+            }).attach(api);
+            
+            // Special endpoint for automated testing
+            // This should be disabled in production
+            if (this.testMode) {
+                app.post('/api/captcha/create-test-token', (req, res) => {
+                    try {
+                        const { token, answer } = req.body;
+                        
+                        if (!token || !answer) {
+                            return res.status(400).json({ 
+                                error: 'Missing token or answer' 
+                            });
+                        }
+                        
+                        // Store the test token with the provided answer
+                        this.captchaTokens.set(token, {
+                            text: answer.toLowerCase(),
+                            expiresAt: Date.now() + this.expirationTime
+                        });
+                        
+                        this.log.debug(`Created test token: ${token} with answer: ${answer}`);
+                        res.json({ success: true });
+                    } catch (error) {
+                        this.log.error(`Error creating test token: ${error.message}`);
+                        res.status(500).json({ error: 'Failed to create test token' });
+                    }
+                });
+            }
+
+            // Diagnostic endpoint - should be used carefully and only during debugging
+            app.get('/api/captcha/diagnostic', (req, res) => {
+                try {
+                    // Get information about the current state
+                    const diagnosticInfo = {
+                        serviceEnabled: this.enabled,
+                        difficulty: this.difficulty,
+                        expirationTime: this.expirationTime,
+                        testMode: this.testMode,
+                        activeTokenCount: this.captchaTokens.size,
+                        serviceId: this.serviceId,
+                        processId: process.pid,
+                        requestCounter: this.requestCounter,
+                        hasStaticTestToken: this.captchaTokens.has('test-static-token'),
+                        tokensState: Array.from(this.captchaTokens).map(([token, data]) => ({
+                            tokenPrefix: token.substring(0, 8) + '...',
+                            expiresAt: new Date(data.expiresAt).toISOString(),
+                            expired: data.expiresAt < Date.now(),
+                            expectedAnswer: data.text
+                        }))
+                    };
+                    
+                    res.json(diagnosticInfo);
+                } catch (error) {
+                    this.log.error(`Error in diagnostic endpoint: ${error.message}`);
+                    res.status(500).json({ error: 'Diagnostic error' });
+                }
+            });
+
+            // Advanced token debugging endpoint - allows testing
+            app.get('/api/captcha/debug-tokens', (req, res) => {
+                try {
+                    // Check if we're the same service instance
+                    const currentTimestamp = Date.now();
+                    const currentTokens = Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8));
+                    
+                    // Create a test token that won't expire soon
+                    const debugToken = 'debug-' + this.crypto.randomBytes(8).toString('hex');
+                    const debugAnswer = 'test123';
+                    
+                    this.captchaTokens.set(debugToken, {
+                        text: debugAnswer,
+                        expiresAt: currentTimestamp + (60 * 60 * 1000) // 1 hour
+                    });
+                    
+                    // Information about the current service instance
+                    const serviceInfo = {
+                        message: 'Debug token created - use for testing captcha validation',
+                        serviceId: this.serviceId,
+                        debugToken: debugToken,
+                        debugAnswer: debugAnswer,
+                        tokensBefore: currentTokens,
+                        tokensAfter: Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)),
+                        currentTokenCount: this.captchaTokens.size,
+                        timestamp: currentTimestamp,
+                        processId: process.pid
+                    };
+                    
+                    res.json(serviceInfo);
+                } catch (error) {
+                    this.log.error(`Error in debug-tokens endpoint: ${error.message}`);
+                    res.status(500).json({ error: 'Debug token creation error' });
+                }
+            });
+
+            // Configuration verification endpoint
+            app.get('/api/captcha/config-status', (req, res) => {
+                try {
+                    // Information about configuration states
+                    const configInfo = {
+                        serviceEnabled: this.enabled,
+                        serviceDifficulty: this.difficulty,
+                        configSource: 'Service configuration',
+                        centralConfig: {
+                            enabled: this.enabled,
+                            difficulty: this.difficulty,
+                            expirationTime: this.expirationTime,
+                            testMode: this.testMode
+                        },
+                        usingCentralizedConfig: true,
+                        configConsistency: this.enabled === (this.enabled === true),
+                        serviceId: this.serviceId,
+                        processId: process.pid
+                    };
+                    
+                    res.json(configInfo);
+                } catch (error) {
+                    this.log.error(`Error in config-status endpoint: ${error.message}`);
+                    res.status(500).json({ error: 'Configuration status error' });
+                }
+            });
+
+            // Test endpoint to validate token lifecycle
+            app.get('/api/captcha/test-lifecycle', (req, res) => {
+                try {
+                    console.log('TOKENS_TRACKING: Running token lifecycle test. Service ID:', this.serviceId);
+                    console.log('TOKENS_TRACKING: Initial token count:', this.captchaTokens.size);
+                    
+                    // Create a test captcha
+                    const testText = 'test123';
+                    const testToken = 'lifecycle-' + this.crypto.randomBytes(16).toString('hex');
+                    
+                    // Store the test token
+                    this.captchaTokens.set(testToken, {
+                        text: testText,
+                        expiresAt: Date.now() + this.expirationTime
+                    });
+                    
+                    console.log('TOKENS_TRACKING: Test token stored. New token count:', this.captchaTokens.size);
+                    
+                    // Verify the token exists
+                    const tokenExists = this.captchaTokens.has(testToken);
+                    console.log('TOKENS_TRACKING: Test token exists in map:', tokenExists);
+                    
+                    // Try to verify with correct answer
+                    const correctVerification = this.verifyCaptcha(testToken, testText);
+                    console.log('TOKENS_TRACKING: Verification with correct answer result:', correctVerification);
+                    
+                    // Check if token was deleted after verification
+                    const tokenAfterVerification = this.captchaTokens.has(testToken);
+                    console.log('TOKENS_TRACKING: Token exists after verification:', tokenAfterVerification);
+                    
+                    // Create another test token
+                    const testToken2 = 'lifecycle2-' + this.crypto.randomBytes(16).toString('hex');
+                    
+                    // Store the test token
+                    this.captchaTokens.set(testToken2, {
+                        text: testText,
+                        expiresAt: Date.now() + this.expirationTime
+                    });
+                    
+                    console.log('TOKENS_TRACKING: Second test token stored. Token count:', this.captchaTokens.size);
+                    
+                    res.json({
+                        message: 'Token lifecycle test completed',
+                        serviceId: this.serviceId,
+                        initialTokens: this.captchaTokens.size - 2, // minus the two we added
+                        tokenCreated: true,
+                        tokenExisted: tokenExists,
+                        verificationResult: correctVerification,
+                        tokenRemovedAfterVerification: !tokenAfterVerification,
+                        secondTokenCreated: this.captchaTokens.has(testToken2),
+                        processId: process.pid
+                    });
+                } catch (error) {
+                    console.error('TOKENS_TRACKING: Error in test-lifecycle endpoint:', error);
+                    res.status(500).json({ error: 'Test lifecycle error' });
+                }
+            });
+
+            this.endpointsRegistered = true;
+            this.log.info('Captcha service endpoints registered successfully');
+            
+            // Emit an event that captcha service is ready
+            try {
+                const eventService = this.services.get('event');
+                if (eventService) {
+                    eventService.emit('service-ready', 'captcha');
+                }
+            } catch (error) {
+                // Ignore errors with event service
+            }
+        } catch (error) {
+            this.log.warn(`Could not register captcha endpoints: ${error.message}`);
+        }
+    }
+
+    /**
+     * Generates a new captcha with a unique token
+     * @returns {Object} Object containing token and SVG image
+     */
+    generateCaptcha() {
+        console.log('====== CAPTCHA GENERATION DIAGNOSTIC ======');
+        console.log('TOKENS_TRACKING: generateCaptcha called. Service ID:', this.serviceId);
+        console.log('TOKENS_TRACKING: Token map size before generation:', this.captchaTokens.size);
+        console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
+        
+        // Increment request counter for diagnostics
+        this.requestCounter++;
+        console.log('TOKENS_TRACKING: Request counter value:', this.requestCounter);
+        
+        console.log('generateCaptcha called, service enabled:', this.enabled);
+        
+        if (!this.enabled) {
+            console.log('Generation SKIPPED: Captcha service is disabled');
+            throw new Error('Captcha service is disabled');
+        }
+
+        // Configure captcha options based on difficulty
+        const options = this._getCaptchaOptions();
+        console.log('Using captcha options for difficulty:', this.difficulty);
+        
+        // Generate the captcha
+        const captcha = this.svgCaptcha.create(options);
+        console.log('Captcha created with text:', captcha.text);
+        
+        // Generate a unique token
+        const token = this.crypto.randomBytes(32).toString('hex');
+        console.log('Generated token:', token.substring(0, 8) + '...');
+        
+        // Store token with captcha text and expiration
+        const expirationTime = Date.now() + this.expirationTime;
+        console.log('Token will expire at:', new Date(expirationTime));
+        
+        console.log('TOKENS_TRACKING: Token map size before storing new token:', this.captchaTokens.size);
+        
+        this.captchaTokens.set(token, {
+            text: captcha.text.toLowerCase(),
+            expiresAt: expirationTime
+        });
+        
+        console.log('TOKENS_TRACKING: Token map size after storing new token:', this.captchaTokens.size);
+        console.log('Token stored in captchaTokens. Current token count:', this.captchaTokens.size);
+        this.log.debug(`Generated captcha with token: ${token}`);
+        
+        return {
+            token: token,
+            data: captcha.data
+        };
+    }
+
+    /**
+     * Verifies a captcha answer against a stored token
+     * @param {string} token - The captcha token
+     * @param {string} userAnswer - The user's answer to verify
+     * @returns {boolean} Whether the answer is valid
+     */
+    verifyCaptcha(token, userAnswer) {
+        console.log('====== CAPTCHA SERVICE VERIFICATION DIAGNOSTIC ======');
+        console.log('TOKENS_TRACKING: verifyCaptcha called. Service ID:', this.serviceId);
+        console.log('TOKENS_TRACKING: Request counter during verification:', this.requestCounter);
+        console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
+        console.log('TOKENS_TRACKING: Trying to verify token:', token ? token.substring(0, 8) + '...' : 'undefined');
+        
+        console.log('verifyCaptcha called with token:', token ? token.substring(0, 8) + '...' : 'undefined');
+        console.log('userAnswer:', userAnswer);
+        console.log('Service enabled:', this.enabled);
+        console.log('Number of tokens in captchaTokens:', this.captchaTokens.size);
+        
+        // Service health check
+        this._checkServiceHealth();
+        
+        if (!this.enabled) {
+            console.log('Verification SKIPPED: Captcha service is disabled');
+            this.log.warn('Captcha verification attempted while service is disabled');
+            throw new Error('Captcha service is disabled');
+        }
+
+        // Get captcha data for token
+        const captchaData = this.captchaTokens.get(token);
+        console.log('Captcha data found for token:', !!captchaData);
+        
+        // Invalid token or expired
+        if (!captchaData) {
+            console.log('Verification FAILED: No data found for this token');
+            console.log('TOKENS_TRACKING: Available tokens (first 8 chars):', 
+                Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)));
+            this.log.debug(`Invalid captcha token: ${token}`);
+            return false;
+        }
+        
+        if (captchaData.expiresAt < Date.now()) {
+            console.log('Verification FAILED: Token expired at:', new Date(captchaData.expiresAt));
+            this.log.debug(`Expired captcha token: ${token}`);
+            return false;
+        }
+        
+        // Normalize and compare answers
+        const normalizedUserAnswer = userAnswer.toLowerCase().trim();
+        console.log('Expected answer:', captchaData.text);
+        console.log('User answer (normalized):', normalizedUserAnswer);
+        const isValid = captchaData.text === normalizedUserAnswer;
+        console.log('Answer comparison result:', isValid);
+        
+        // Remove token after verification (one-time use)
+        this.captchaTokens.delete(token);
+        console.log('Token removed after verification (one-time use)');
+        console.log('TOKENS_TRACKING: Token map size after removing used token:', this.captchaTokens.size);
+        
+        this.log.debug(`Verified captcha token: ${token}, valid: ${isValid}`);
+        return isValid;
+    }
+    
+    /**
+     * Simple diagnostic method to check service health
+     * @private
+     */
+    _checkServiceHealth() {
+        console.log('TOKENS_TRACKING: Service health check. ID:', this.serviceId, 'Token count:', this.captchaTokens.size);
+        return true;
+    }
+
+    /**
+     * Removes expired captcha tokens from memory
+     */
+    cleanupExpiredTokens() {
+        console.log('TOKENS_TRACKING: Running token cleanup. Service ID:', this.serviceId);
+        console.log('TOKENS_TRACKING: Token map size before cleanup:', this.captchaTokens.size);
+        
+        const now = Date.now();
+        let expiredCount = 0;
+        let validCount = 0;
+        
+        // Log all tokens before cleanup
+        console.log('TOKENS_TRACKING: Current tokens before cleanup:');
+        for (const [token, data] of this.captchaTokens.entries()) {
+            const isExpired = data.expiresAt < now;
+            console.log(`TOKENS_TRACKING: Token ${token.substring(0, 8)}... expires: ${new Date(data.expiresAt).toISOString()}, expired: ${isExpired}`);
+            
+            if (isExpired) {
+                expiredCount++;
+            } else {
+                validCount++;
+            }
+        }
+        
+        // Only do the actual cleanup if we found expired tokens
+        if (expiredCount > 0) {
+            console.log(`TOKENS_TRACKING: Found ${expiredCount} expired tokens to remove and ${validCount} valid tokens to keep`);
+            
+            // Clean up expired tokens
+            for (const [token, data] of this.captchaTokens.entries()) {
+                if (data.expiresAt < now) {
+                    this.captchaTokens.delete(token);
+                    console.log(`TOKENS_TRACKING: Deleted expired token: ${token.substring(0, 8)}...`);
+                }
+            }
+        } else {
+            console.log('TOKENS_TRACKING: No expired tokens found, skipping cleanup');
+        }
+        
+        // Skip cleanup for the static test token
+        if (this.captchaTokens.has('test-static-token')) {
+            console.log('TOKENS_TRACKING: Static test token still exists after cleanup');
+        } else {
+            console.log('TOKENS_TRACKING: WARNING - Static test token was removed during cleanup');
+            
+            // Restore the static test token for diagnostic purposes
+            this.captchaTokens.set('test-static-token', {
+                text: 'testanswer',
+                expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
+            });
+            console.log('TOKENS_TRACKING: Restored static test token');
+        }
+        
+        console.log('TOKENS_TRACKING: Token map size after cleanup:', this.captchaTokens.size);
+        
+        if (expiredCount > 0) {
+            this.log.debug(`Cleaned up ${expiredCount} expired captcha tokens`);
+        }
+    }
+
+    /**
+     * Gets captcha options based on the configured difficulty
+     * @private
+     * @returns {Object} Captcha configuration options
+     */
+    _getCaptchaOptions() {
+        const baseOptions = {
+            size: 6, // Default captcha length
+            ignoreChars: '0o1ilI', // Characters to avoid (confusing)
+            noise: 2, // Lines to add as noise
+            color: true,
+            background: '#f0f0f0'
+        };
+        
+        switch (this.difficulty) {
+            case 'easy':
+                return {
+                    ...baseOptions,
+                    size: 4,
+                    width: 150,
+                    height: 50,
+                    noise: 1
+                };
+            case 'hard':
+                return {
+                    ...baseOptions,
+                    size: 7,
+                    width: 200,
+                    height: 60,
+                    noise: 3
+                };
+            case 'medium':
+            default:
+                return {
+                    ...baseOptions,
+                    width: 180,
+                    height: 50
+                };
+        }
+    }
+
+    /**
+     * Verifies that the captcha service is properly configured and working
+     * This is used during initialization and can be called to check system status
+     * @returns {boolean} Whether the service is properly configured and functioning
+     */
+    verifySelfTest() {
+        try {
+            // Ensure required dependencies are available
+            if (!this.svgCaptcha) {
+                this.log.error('Captcha service self-test failed: svg-captcha module not available');
+                return false;
+            }
+            
+            if (!this.enabled) {
+                this.log.warn('Captcha service self-test failed: service is disabled');
+                return false;
+            }
+            
+            // Validate configuration
+            if (!this.expirationTime || typeof this.expirationTime !== 'number') {
+                this.log.error('Captcha service self-test failed: invalid expiration time configuration');
+                return false;
+            }
+            
+            // Basic functionality test - generate a test captcha and verify storage
+            const testToken = 'test-' + this.crypto.randomBytes(8).toString('hex');
+            const testText = 'testcaptcha';
+            
+            // Store the test captcha
+            this.captchaTokens.set(testToken, {
+                text: testText,
+                expiresAt: Date.now() + this.expirationTime
+            });
+            
+            // Verify the test captcha
+            const correctVerification = this.verifyCaptcha(testToken, testText);
+            
+            // Check if verification worked and token was removed
+            if (!correctVerification || this.captchaTokens.has(testToken)) {
+                this.log.error('Captcha service self-test failed: verification test failed');
+                return false;
+            }
+            
+            this.log.debug('Captcha service self-test passed');
+            return true;
+        } catch (error) {
+            this.log.error(`Captcha service self-test failed with error: ${error.message}`);
+            return false;
+        }
+    }
+
+    /**
+     * Returns the service's diagnostic information
+     * @returns {Object} Diagnostic information about the service
+     */
+    getDiagnosticInfo() {
+        return {
+            serviceId: this.serviceId,
+            enabled: this.enabled,
+            tokenCount: this.captchaTokens.size,
+            requestCounter: this.requestCounter,
+            config: {
+                enabled: this.enabled,
+                difficulty: this.difficulty,
+                expirationTime: this.expirationTime,
+                testMode: this.testMode
+            },
+            processId: process.pid,
+            testTokenExists: this.captchaTokens.has('test-static-token')
+        };
+    }
+}
+
+// Export both as a named export and as a default export for compatibility
+module.exports = CaptchaService;
+module.exports.CaptchaService = CaptchaService; 

+ 17 - 3
src/backend/src/routers/login.js

@@ -22,6 +22,7 @@ const router = new express.Router();
 const { get_user, body_parser_error_handler } = require('../helpers');
 const config = require('../config');
 const { DB_WRITE } = require('../services/database/consts');
+const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
 
 
 const complete_ = async ({ req, res, user }) => {
@@ -55,7 +56,20 @@ const complete_ = async ({ req, res, user }) => {
 // -----------------------------------------------------------------------//
 // POST /file
 // -----------------------------------------------------------------------//
-router.post('/login', express.json(), body_parser_error_handler, async (req, res, next)=>{
+router.post('/login', express.json(), body_parser_error_handler, 
+    // Add diagnostic middleware to log captcha data
+    (req, res, next) => {
+        console.log('====== LOGIN CAPTCHA DIAGNOSTIC ======');
+        console.log('LOGIN REQUEST RECEIVED with captcha data:', {
+            hasCaptchaToken: !!req.body.captchaToken,
+            hasCaptchaAnswer: !!req.body.captchaAnswer,
+            captchaToken: req.body.captchaToken ? req.body.captchaToken.substring(0, 8) + '...' : undefined,
+            captchaAnswer: req.body.captchaAnswer
+        });
+        next();
+    },
+    requireCaptcha({ strictMode: true, eventType: 'login' }), 
+    async (req, res, next)=>{
     // either api. subdomain or no subdomain
     if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
         next();
@@ -151,7 +165,7 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res
 
 })
 
-router.post('/login/otp', express.json(), body_parser_error_handler, async (req, res, next) => {
+router.post('/login/otp', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_otp' }), async (req, res, next) => {
     // either api. subdomain or no subdomain
     if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
         next();
@@ -207,7 +221,7 @@ router.post('/login/otp', express.json(), body_parser_error_handler, async (req,
     return await complete_({ req, res, user });
 });
 
-router.post('/login/recovery-code', express.json(), body_parser_error_handler, async (req, res, next) => {
+router.post('/login/recovery-code', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_recovery' }), async (req, res, next) => {
     // either api. subdomain or no subdomain
     if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
         next();

+ 2 - 0
src/backend/src/routers/signup.js

@@ -25,6 +25,7 @@ const { DB_WRITE } = require('../services/database/consts');
 const { generate_identifier } = require('../util/identifier');
 const { is_temp_users_disabled: lazy_temp_users, 
         is_user_signup_disabled: lazy_user_signup } = require("../helpers")
+const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
 
 async function generate_random_username () {
     let username;
@@ -48,6 +49,7 @@ module.exports = eggspress(['/signup'], {
             res.status(400).send(`email username mismatch; please provide a password`);
         }
     },
+    mw: [requireCaptcha({ strictMode: true, eventType: 'signup' })], // Conditionally require captcha for signup
 }, async (req, res, next) => {
     // either api. subdomain or no subdomain
     if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')

+ 24 - 2
src/backend/src/services/PuterHomepageService.js

@@ -21,6 +21,7 @@ const { PathBuilder } = require("../util/pathutil");
 const BaseService = require("./BaseService");
 const {is_valid_url} = require('../helpers');
 const { Endpoint } = require("../util/expressutil");
+const { Context } = require("../util/context");
 
 /**
  * PuterHomepageService serves the initial HTML page that loads the Puter GUI
@@ -72,10 +73,23 @@ class PuterHomepageService extends BaseService {
             route: '/whoarewe',
             methods: ['GET'],
             handler: async (req, res) => {
-                res.json({
+                // Get basic configuration information
+                const responseData = {
                     disable_user_signup: this.global_config.disable_user_signup,
                     disable_temp_users: this.global_config.disable_temp_users,
-                });
+                    environmentInfo: {
+                        env: this.global_config.env,
+                        version: process.env.VERSION || 'development'
+                    }
+                };
+
+                // Add captcha requirement information
+                responseData.captchaRequired = {
+                    login: req.captchaRequired,
+                    signup: req.captchaRequired,
+                };
+                
+                res.json(responseData);
             }
         }).attach(app);
     }
@@ -106,6 +120,12 @@ class PuterHomepageService extends BaseService {
             }));
         }
         
+        // checkCaptcha middleware (in CaptchaService) sets req.captchaRequired
+        const captchaRequired = {
+            login: req.captchaRequired,
+            signup: req.captchaRequired,
+        };
+        
         return res.send(this.generate_puter_page_html({
             env: config.env,
 
@@ -144,6 +164,8 @@ class PuterHomepageService extends BaseService {
                 long_description: config.long_description,
                 disable_temp_users: config.disable_temp_users,
                 co_isolation_enabled: req.co_isolation_enabled,
+                // Add captcha requirements to GUI parameters
+                captchaRequired: captchaRequired,
             },
         }));
     }

+ 268 - 0
src/backend/test/modules/captcha/integration/extension-integration.test.js

@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const { describe, it, beforeEach, afterEach } = require('mocha');
+const { expect } = require('chai');
+const sinon = require('sinon');
+
+// Mock the Context and services
+const Context = {
+    get: sinon.stub()
+};
+
+// Mock the extension service
+class ExtensionService {
+    constructor() {
+        this.extensions = new Map();
+        this.eventHandlers = new Map();
+    }
+    
+    registerExtension(name, extension) {
+        this.extensions.set(name, extension);
+    }
+    
+    on(event, handler) {
+        if (!this.eventHandlers.has(event)) {
+            this.eventHandlers.set(event, []);
+        }
+        this.eventHandlers.get(event).push(handler);
+    }
+    
+    async emit(event, data) {
+        const handlers = this.eventHandlers.get(event) || [];
+        for (const handler of handlers) {
+            await handler(data);
+        }
+    }
+}
+
+describe('Extension Integration with Captcha', () => {
+    let extensionService, captchaService, services;
+    
+    beforeEach(() => {
+        // Reset stubs
+        sinon.reset();
+        
+        // Create fresh instances
+        extensionService = new ExtensionService();
+        captchaService = {
+            enabled: true,
+            verifyCaptcha: sinon.stub()
+        };
+        
+        services = {
+            get: sinon.stub()
+        };
+        
+        // Configure service mocks
+        services.get.withArgs('extension').returns(extensionService);
+        services.get.withArgs('captcha').returns(captchaService);
+        
+        // Configure Context mock
+        Context.get.withArgs('services').returns(services);
+    });
+    
+    describe('Extension Event Handling', () => {
+        it('should allow extensions to require captcha via event handler', async () => {
+            // Setup - create a test extension that requires captcha
+            const testExtension = {
+                name: 'test-extension',
+                onCaptchaValidate: async (event) => {
+                    if (event.type === 'login' && event.ip === '1.2.3.4') {
+                        event.require = true;
+                    }
+                }
+            };
+            
+            // Register extension and event handler
+            extensionService.registerExtension(testExtension.name, testExtension);
+            extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
+            
+            // Test event emission
+            const eventData = {
+                type: 'login',
+                ip: '1.2.3.4',
+                require: false
+            };
+            
+            await extensionService.emit('captcha.validate', eventData);
+            
+            // Assert
+            expect(eventData.require).to.be.true;
+        });
+        
+        it('should allow extensions to disable captcha requirement', async () => {
+            // Setup - create a test extension that disables captcha
+            const testExtension = {
+                name: 'test-extension',
+                onCaptchaValidate: async (event) => {
+                    if (event.type === 'login' && event.ip === 'trusted-ip') {
+                        event.require = false;
+                    }
+                }
+            };
+            
+            // Register extension and event handler
+            extensionService.registerExtension(testExtension.name, testExtension);
+            extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
+            
+            // Test event emission
+            const eventData = {
+                type: 'login',
+                ip: 'trusted-ip',
+                require: true
+            };
+            
+            await extensionService.emit('captcha.validate', eventData);
+            
+            // Assert
+            expect(eventData.require).to.be.false;
+        });
+        
+        it('should handle multiple extensions modifying captcha requirement', async () => {
+            // Setup - create two test extensions with different rules
+            const extension1 = {
+                name: 'extension-1',
+                onCaptchaValidate: async (event) => {
+                    if (event.type === 'login') {
+                        event.require = true;
+                    }
+                }
+            };
+            
+            const extension2 = {
+                name: 'extension-2',
+                onCaptchaValidate: async (event) => {
+                    if (event.ip === 'trusted-ip') {
+                        event.require = false;
+                    }
+                }
+            };
+            
+            // Register extensions and event handlers
+            extensionService.registerExtension(extension1.name, extension1);
+            extensionService.registerExtension(extension2.name, extension2);
+            extensionService.on('captcha.validate', extension1.onCaptchaValidate);
+            extensionService.on('captcha.validate', extension2.onCaptchaValidate);
+            
+            // Test event emission - extension2 should override extension1
+            const eventData = {
+                type: 'login',
+                ip: 'trusted-ip',
+                require: false
+            };
+            
+            await extensionService.emit('captcha.validate', eventData);
+            
+            // Assert
+            expect(eventData.require).to.be.false;
+        });
+        
+        it('should handle extension errors gracefully', async () => {
+            // Setup - create a test extension that throws an error
+            const testExtension = {
+                name: 'test-extension',
+                onCaptchaValidate: async () => {
+                    throw new Error('Extension error');
+                }
+            };
+            
+            // Register extension and event handler
+            extensionService.registerExtension(testExtension.name, testExtension);
+            extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
+            
+            // Test event emission
+            const eventData = {
+                type: 'login',
+                ip: '1.2.3.4',
+                require: false
+            };
+            
+            // The emit should not throw
+            await extensionService.emit('captcha.validate', eventData);
+            
+            // Assert - the original value should be preserved
+            expect(eventData.require).to.be.false;
+        });
+    });
+    
+    describe('Backward Compatibility', () => {
+        it('should maintain backward compatibility with older extension APIs', async () => {
+            // Setup - create a test extension using the old API format
+            const legacyExtension = {
+                name: 'legacy-extension',
+                handleCaptcha: async (event) => {
+                    event.require = true;
+                }
+            };
+            
+            // Register legacy extension with old event name
+            extensionService.registerExtension(legacyExtension.name, legacyExtension);
+            extensionService.on('captcha.check', legacyExtension.handleCaptcha);
+            
+            // Test both old and new event names
+            const eventData = {
+                type: 'login',
+                ip: '1.2.3.4',
+                require: false
+            };
+            
+            // Should work with both old and new event names
+            await extensionService.emit('captcha.check', eventData);
+            await extensionService.emit('captcha.validate', eventData);
+            
+            // Assert - the requirement should be set by the legacy extension
+            expect(eventData.require).to.be.true;
+        });
+        
+        it('should support legacy extension configuration formats', async () => {
+            // Setup - create a test extension with legacy configuration
+            const legacyExtension = {
+                name: 'legacy-extension',
+                config: {
+                    captcha: {
+                        always: true,
+                        types: ['login', 'signup']
+                    }
+                },
+                onCaptchaValidate: async (event) => {
+                    if (legacyExtension.config.captcha.types.includes(event.type)) {
+                        event.require = legacyExtension.config.captcha.always;
+                    }
+                }
+            };
+            
+            // Register extension and event handler
+            extensionService.registerExtension(legacyExtension.name, legacyExtension);
+            extensionService.on('captcha.validate', legacyExtension.onCaptchaValidate);
+            
+            // Test event emission
+            const eventData = {
+                type: 'login',
+                ip: '1.2.3.4',
+                require: false
+            };
+            
+            await extensionService.emit('captcha.validate', eventData);
+            
+            // Assert
+            expect(eventData.require).to.be.true;
+        });
+    });
+}); 

+ 279 - 0
src/backend/test/modules/captcha/middleware/captcha-middleware.test.js

@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const { describe, it, beforeEach, afterEach } = require('mocha');
+const { expect } = require('chai');
+const sinon = require('sinon');
+
+// Mock the Context
+const Context = {
+    get: sinon.stub()
+};
+
+// Mock the APIError
+const APIError = {
+    create: sinon.stub().returns({ name: 'APIError' })
+};
+
+// Path is relative to where the test will be run
+const { checkCaptcha, requireCaptcha } = require('../../../../src/modules/captcha/middleware/captcha-middleware');
+
+describe('Captcha Middleware', () => {
+    let req, res, next, services, captchaService, eventService;
+    
+    beforeEach(() => {
+        // Reset all stubs
+        sinon.reset();
+        
+        // Mock request, response, and next function
+        req = {
+            ip: '127.0.0.1',
+            headers: {
+                'user-agent': 'test-agent'
+            },
+            body: {},
+            connection: {
+                remoteAddress: '127.0.0.1'
+            }
+        };
+        
+        res = {
+            status: sinon.stub().returnsThis(),
+            json: sinon.stub().returnsThis()
+        };
+        
+        next = sinon.stub();
+        
+        // Mock services
+        captchaService = {
+            enabled: true,
+            verifyCaptcha: sinon.stub()
+        };
+        
+        eventService = {
+            emit: sinon.stub().resolves()
+        };
+        
+        services = {
+            get: sinon.stub()
+        };
+        
+        // Configure service mocks
+        services.get.withArgs('captcha').returns(captchaService);
+        services.get.withArgs('event').returns(eventService);
+        
+        // Configure Context mock
+        Context.get.withArgs('services').returns(services);
+    });
+    
+    describe('checkCaptcha', () => {
+        it('should set captchaRequired to false when not required', async () => {
+            // Setup
+            const middleware = checkCaptcha({ strictMode: false });
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(req.captchaRequired).to.be.false;
+            expect(next.calledOnce).to.be.true;
+        });
+        
+        it('should set captchaRequired to true when always option is true', async () => {
+            // Setup
+            const middleware = checkCaptcha({ always: true });
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(req.captchaRequired).to.be.true;
+            expect(next.calledOnce).to.be.true;
+        });
+        
+        it('should set captchaRequired to true when requester.requireCaptcha is true', async () => {
+            // Setup
+            req.requester = { requireCaptcha: true };
+            const middleware = checkCaptcha();
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(req.captchaRequired).to.be.true;
+            expect(next.calledOnce).to.be.true;
+        });
+        
+        it('should emit captcha.validate event with correct parameters', async () => {
+            // Setup
+            const middleware = checkCaptcha({ eventType: 'login' });
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(eventService.emit.calledOnce).to.be.true;
+            expect(eventService.emit.firstCall.args[0]).to.equal('captcha.validate');
+            
+            const eventData = eventService.emit.firstCall.args[1];
+            expect(eventData.type).to.equal('login');
+            expect(eventData.ip).to.equal('127.0.0.1');
+            expect(eventData.userAgent).to.equal('test-agent');
+            expect(eventData.req).to.equal(req);
+        });
+        
+        it('should respect extension decision to require captcha', async () => {
+            // Setup
+            eventService.emit.callsFake((event, data) => {
+                data.require = true;
+                return Promise.resolve();
+            });
+            
+            const middleware = checkCaptcha({ strictMode: false });
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(req.captchaRequired).to.be.true;
+            expect(next.calledOnce).to.be.true;
+        });
+        
+        it('should respect extension decision to not require captcha', async () => {
+            // Setup
+            eventService.emit.callsFake((event, data) => {
+                data.require = false;
+                return Promise.resolve();
+            });
+            
+            const middleware = checkCaptcha({ always: true });
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(req.captchaRequired).to.be.false;
+            expect(next.calledOnce).to.be.true;
+        });
+        
+        it('should default to strictMode value when services are not available', async () => {
+            // Setup
+            Context.get.withArgs('services').returns(null);
+            
+            // Test with strictMode true
+            let middleware = checkCaptcha({ strictMode: true });
+            await middleware(req, res, next);
+            expect(req.captchaRequired).to.be.true;
+            
+            // Reset
+            req = { headers: {}, connection: { remoteAddress: '127.0.0.1' } };
+            next = sinon.stub();
+            
+            // Test with strictMode false
+            middleware = checkCaptcha({ strictMode: false });
+            await middleware(req, res, next);
+            expect(req.captchaRequired).to.be.false;
+        });
+    });
+    
+    describe('requireCaptcha', () => {
+        it('should call next() when captchaRequired is false', async () => {
+            // Setup
+            req.captchaRequired = false;
+            const middleware = requireCaptcha();
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(next.calledOnce).to.be.true;
+            expect(next.firstCall.args.length).to.equal(0); // No error passed
+        });
+        
+        it('should return error when captchaRequired is true but token/answer missing', async () => {
+            // Setup
+            req.captchaRequired = true;
+            const middleware = requireCaptcha();
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(next.calledOnce).to.be.true;
+            expect(next.firstCall.args.length).to.equal(1); // Error passed
+            expect(APIError.create.calledWith('captcha_required')).to.be.true;
+        });
+        
+        it('should verify captcha when token and answer are provided', async () => {
+            // Setup
+            req.captchaRequired = true;
+            req.body.captchaToken = 'test-token';
+            req.body.captchaAnswer = 'test-answer';
+            captchaService.verifyCaptcha.returns(true);
+            
+            const middleware = requireCaptcha();
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
+            expect(next.calledOnce).to.be.true;
+            expect(next.firstCall.args.length).to.equal(0); // No error passed
+        });
+        
+        it('should return error when captcha verification fails', async () => {
+            // Setup
+            req.captchaRequired = true;
+            req.body.captchaToken = 'test-token';
+            req.body.captchaAnswer = 'test-answer';
+            captchaService.verifyCaptcha.returns(false);
+            
+            const middleware = requireCaptcha();
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
+            expect(next.calledOnce).to.be.true;
+            expect(next.firstCall.args.length).to.equal(1); // Error passed
+            expect(APIError.create.calledWith('captcha_invalid')).to.be.true;
+        });
+        
+        it('should handle errors during captcha verification', async () => {
+            // Setup
+            req.captchaRequired = true;
+            req.body.captchaToken = 'test-token';
+            req.body.captchaAnswer = 'test-answer';
+            captchaService.verifyCaptcha.throws(new Error('Verification error'));
+            
+            const middleware = requireCaptcha();
+            
+            // Test
+            await middleware(req, res, next);
+            
+            // Assert
+            expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
+            expect(next.calledOnce).to.be.true;
+            expect(next.firstCall.args.length).to.equal(1); // Error passed
+            expect(APIError.create.calledWith('captcha_invalid')).to.be.true;
+        });
+    });
+}); 

+ 4 - 1
src/gui/package.json

@@ -12,6 +12,7 @@
   },
   "devDependencies": {
     "@eslint/js": "^9.1.1",
+    "chai": "^4.3.7",
     "chalk": "^4.1.0",
     "clean-css": "^5.3.2",
     "dotenv": "^16.4.5",
@@ -19,13 +20,15 @@
     "express": "^4.18.2",
     "globals": "^15.0.0",
     "html-entities": "^2.3.3",
+    "jsdom": "^21.1.0",
     "nodemon": "^3.1.0",
+    "sinon": "^15.0.1",
     "uglify-js": "^3.17.4",
     "webpack": "^5.88.2",
     "webpack-cli": "^5.1.1"
   },
   "scripts": {
-    "test": "mocha ./packages/phoenix/test ./packages/phoenix/packages/contextlink/test",
+    "test": "mocha ./test/**/*.test.js",
     "start=gui": "nodemon --exec \"node dev-server.js\" ",
     "build": "node ./build.js",
     "check-translations": "node tools/check-translations.js",

+ 285 - 0
src/gui/src/UI/Components/CaptchaView.js

@@ -0,0 +1,285 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/**
+ * CaptchaView - A component for displaying and handling captcha challenges
+ * 
+ * @param {Object} options - Configuration options
+ * @param {HTMLElement} options.container - The container element to attach the captcha to
+ * @param {Function} options.onReady - Callback when the captcha is ready
+ * @param {Function} options.onError - Callback for handling errors
+ * @param {boolean} options.required - Whether captcha is required (will not display if false)
+ * @param {Function} options.onRequiredChange - Callback when the required status changes
+ * @returns {Object} - Methods to interact with the captcha
+ */
+function CaptchaView(options = {}) {
+    // Internal state
+    const state = {
+        token: null,
+        image: null,
+        answer: '',
+        loading: false,
+        error: null,
+        container: options.container || document.createElement('div'),
+        required: options.required !== undefined ? options.required : true, // Default to required
+        initialized: false
+    };
+
+    // Create the initial DOM structure
+    const init = () => {
+        const container = state.container;
+        container.classList.add('captcha-view-container');
+        container.style.marginTop = '20px';
+        container.style.marginBottom = '20px';
+        
+        // Add container CSS
+        container.style.display = state.required ? 'flex' : 'none';
+        container.style.flexDirection = 'column';
+        container.style.gap = '10px';
+        
+        state.initialized = true;
+        
+        // Render the initial HTML
+        render();
+        
+        // Only fetch captcha if required
+        if (state.required) {
+            refresh();
+        }
+    };
+
+    // Set whether captcha is required
+    const setRequired = (required) => {
+        if (state.required === required) return; // No change
+        
+        state.required = required;
+        
+        if (state.initialized) {
+            // Update display
+            state.container.style.display = required ? 'flex' : 'none';
+            
+            // If becoming required and no captcha loaded, fetch one
+            if (required && !state.token) {
+                refresh();
+            }
+            
+            // Notify of change if callback provided
+            if (typeof options.onRequiredChange === 'function') {
+                options.onRequiredChange(required);
+            }
+        }
+    };
+
+    // Render the captcha HTML
+    const render = () => {
+        const container = state.container;
+        
+        // Clear the container
+        container.innerHTML = '';
+        
+        // Label
+        const label = document.createElement('label');
+        label.textContent = i18n('captcha_verification');
+        label.setAttribute('for', `captcha-input-${Date.now()}`);
+        container.appendChild(label);
+        
+        // Captcha wrapper
+        const captchaWrapper = document.createElement('div');
+        captchaWrapper.classList.add('captcha-wrapper');
+        captchaWrapper.style.display = 'flex';
+        captchaWrapper.style.flexDirection = 'column';
+        captchaWrapper.style.gap = '10px';
+        container.appendChild(captchaWrapper);
+        
+        // Captcha image and refresh button container
+        const imageContainer = document.createElement('div');
+        imageContainer.style.display = 'flex';
+        imageContainer.style.alignItems = 'center';
+        imageContainer.style.justifyContent = 'space-between';
+        imageContainer.style.gap = '10px';
+        imageContainer.style.border = '1px solid #ced7e1';
+        imageContainer.style.borderRadius = '4px';
+        imageContainer.style.padding = '10px';
+        captchaWrapper.appendChild(imageContainer);
+        
+        // Captcha image
+        const imageElement = document.createElement('div');
+        imageElement.classList.add('captcha-image');
+        
+        if (state.loading) {
+            imageElement.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:50px;"><span style="font-size:14px;">Loading captcha...</span></div>';
+        } else if (state.error) {
+            imageElement.innerHTML = `<div style="color:red;padding:10px;">${state.error}</div>`;
+        } else if (state.image) {
+            imageElement.innerHTML = state.image;
+            // Make SVG responsive
+            const svgElement = imageElement.querySelector('svg');
+            if (svgElement) {
+                svgElement.style.width = '100%';
+                svgElement.style.height = 'auto';
+            }
+        } else {
+            imageElement.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:50px;"><span style="font-size:14px;">No captcha loaded</span></div>';
+        }
+        imageContainer.appendChild(imageElement);
+        
+        // Refresh button
+        const refreshButton = document.createElement('button');
+        refreshButton.classList.add('button', 'button-small');
+        refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i>';
+        refreshButton.setAttribute('title', i18n('refresh_captcha'));
+        refreshButton.style.minWidth = '30px';
+        refreshButton.style.height = '30px';
+        refreshButton.setAttribute('type', 'button');
+        refreshButton.addEventListener('click', (e) => {
+            e.preventDefault();
+            e.stopPropagation();
+            refresh();
+        });
+        imageContainer.appendChild(refreshButton);
+        
+        // Input field
+        const inputField = document.createElement('input');
+        inputField.id = `captcha-input-${Date.now()}`;
+        inputField.classList.add('captcha-input');
+        inputField.type = 'text';
+        inputField.placeholder = i18n('enter_captcha_text');
+        inputField.setAttribute('autocomplete', 'off');
+        inputField.setAttribute('spellcheck', 'false');
+        inputField.setAttribute('autocorrect', 'off');
+        inputField.setAttribute('autocapitalize', 'off');
+        inputField.value = state.answer || '';
+        inputField.addEventListener('input', (e) => {
+            state.answer = e.target.value;
+        });
+        
+        // Prevent Enter key from triggering refresh and allow it to submit the form
+        inputField.addEventListener('keydown', (e) => {
+            if (e.key === 'Enter') {
+                // Don't prevent default here - let Enter bubble up to the form
+                // Just make sure we don't refresh the captcha
+                e.stopPropagation();
+            }
+        });
+        
+        captchaWrapper.appendChild(inputField);
+        
+        // Helper text
+        const helperText = document.createElement('div');
+        helperText.classList.add('captcha-helper-text');
+        helperText.style.fontSize = '12px';
+        helperText.style.color = '#666';
+        helperText.textContent = i18n('captcha_case_sensitive');
+        captchaWrapper.appendChild(helperText);
+    };
+
+    // Fetch a new captcha
+    const refresh = async () => {
+        // Skip if not required
+        if (!state.required) {
+            return;
+        }
+        
+        try {
+            state.loading = true;
+            state.error = null;
+            render();
+            
+            const response = await fetch(window.gui_origin + '/api/captcha/generate');
+            
+            if (!response.ok) {
+                throw new Error(`Failed to load captcha: ${response.status}`);
+            }
+            
+            const data = await response.json();
+            
+            state.token = data.token;
+            state.image = data.image;
+            state.loading = false;
+            
+            render();
+            
+            if (typeof options.onReady === 'function') {
+                options.onReady();
+            }
+        } catch (error) {
+            state.loading = false;
+            state.error = error.message || 'Failed to load captcha';
+            
+            render();
+            
+            if (typeof options.onError === 'function') {
+                options.onError(error);
+            }
+        }
+    };
+
+    // Public API
+    const api = {
+        /**
+         * Get the current captcha token
+         * @returns {string} The captcha token
+         */
+        getToken: () => state.token,
+        
+        /**
+         * Get the current captcha answer
+         * @returns {string} The user's answer
+         */
+        getAnswer: () => state.answer,
+        
+        /**
+         * Reset the captcha - clear answer and get a new challenge
+         */
+        reset: () => {
+            state.answer = '';
+            refresh();
+        },
+        
+        /**
+         * Get the container element
+         * @returns {HTMLElement} The container element
+         */
+        getElement: () => state.container,
+        
+        /**
+         * Check if captcha is required
+         * @returns {boolean} Whether captcha is required
+         */
+        isRequired: () => state.required,
+        
+        /**
+         * Set whether captcha is required
+         * @param {boolean} required - Whether captcha is required
+         */
+        setRequired: setRequired
+    };
+    
+    // Set initial required state from options
+    if (options.required !== undefined) {
+        state.required = options.required;
+    }
+    
+    // Initialize the component
+    init();
+    
+    return api;
+}
+
+export default CaptchaView; 

+ 265 - 33
src/gui/src/UI/UIWindowLogin.js

@@ -29,43 +29,61 @@ import StepView from './Components/StepView.js';
 import Button from './Components/Button.js';
 import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js';
 import play_startup_chime from '../helpers/play_startup_chime.js';
+import CaptchaView from './Components/CaptchaView.js'
+import { isCaptchaRequired } from '../helpers/captchaHelper.js';
 
 async function UIWindowLogin(options){
     options = options ?? {};
-    options.reload_on_success = options.reload_on_success ?? false;
-    options.has_head = options.has_head ?? true;
-    options.send_confirmation_code = options.send_confirmation_code ?? false;
-    options.show_password = options.show_password ?? false;
-
+    
+    if(options.reload_on_success === undefined)
+        options.reload_on_success = true;
+    
     return new Promise(async (resolve) => {
         const internal_id = window.uuidv4();
+        
+        // Check if captcha is required for login
+        const captchaRequired = await isCaptchaRequired('login');
+        console.log('Login captcha required:', captchaRequired);
+        
         let h = ``;
-        h += `<div style="max-width: 500px; min-width: 340px;">`;
-            if(!options.has_head && options.show_close_button !== false)
-                h += `<div class="generic-close-window-button"> &times; </div>`;
-            h += `<div style="padding: 20px; border-bottom: 1px solid #ced7e1; width: 100%; box-sizing: border-box;">`;
-                // title
-                h += `<h1 class="login-form-title">${i18n('log_in')}</h1>`;
-                // login form
-                h += `<form class="login-form">`;
-                    // error msg
-                    h += `<div class="login-error-msg"></div>`;
-                    // username/email
-                    h += `<div style="overflow: hidden;">`;
-                        h += `<label for="email_or_username-${internal_id}">${i18n('email_or_username')}</label>`;
-                        h += `<input id="email_or_username-${internal_id}" class="email_or_username" type="text" name="email_or_username" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false" autocomplete="username"/>`;
+        h += `<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:flex-start; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">`;
+            // logo
+            h += `<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">`;
+                h += `<img src="/dist/images/logo/logo.svg" style="height:45px;" />`;
+            h += `</div>`;
+            // title
+            h += `<div style="padding:10px 20px; text-align:center; margin-bottom:0;">`;
+                h += `<h1 style="font-size:18px; margin-bottom:0;">${i18n('log_in')}</h1>`;
+            h += `</div>`;
+            // form
+            h += `<div style="padding:20px; overflow-y:auto; overflow-x:hidden;">`;
+                h += `<form class="login-form" style="width:100%;">`;
+                    // server messages
+                    h += `<div class="login-error-msg" style="color:#e74c3c; display:none; margin-bottom:10px; line-height:15px; font-size:13px;"></div>`;
+                    // email or username
+                    h += `<div style="position: relative; margin-bottom: 20px;">`;
+                    h += `<label style="display:block; margin-bottom:5px;">${i18n('email_or_username')}</label>`;
+                    if(options.email_or_username){
+                        h += `<input type="text" class="email_or_username" value="${options.email_or_username}" autocomplete="username"/>`;
+                    }else{
+                        h += `<input type="text" class="email_or_username" autocomplete="username"/>`;
+                    }
                     h += `</div>`;
-                    // password with conditional type based based on options.show_password
-                    h += `<div style="overflow: hidden; margin-top: 20px; margin-bottom: 20px; position: relative;">`;
-                    h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
+                    // password
+                    h += `<div style="position: relative; margin-bottom: 20px;">`;
+                    h += `<label style="display:block; margin-bottom:5px;">${i18n('password')}</label>`;
                     h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? "text" : "password"}" name="password" autocomplete="current-password"/>`;
                     // show/hide icon
                     h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
                                 <img class="toggle-show-password-icon" src="${options.show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"]}" width="20" height="20">
                             </span>`;
                     h += `</div>`;
+                    // captcha placeholder - will be replaced with actual captcha component
+                    h += `<div class="captcha-container"></div>`;
+                    // captcha-specific error message
+                    h += `<div class="captcha-error-msg" style="color: #e74c3c; font-size: 12px; margin-top: 5px; display: none;" aria-live="polite"></div>`;
                     // login
-                    h += `<button class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
+                    h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
                     // password recovery
                     h += `<p style="text-align:center; margin-bottom: 0;"><span class="forgot-password-link">${i18n('forgot_pass_c2a')}</span></p>`;
                 h += `</form>`;
@@ -124,6 +142,46 @@ async function UIWindowLogin(options){
             }    
         })
 
+        // Initialize the captcha component with the required state
+        const captchaContainer = $(el_window).find('.captcha-container')[0];
+        const captcha = CaptchaView({ 
+            container: captchaContainer,
+            required: captchaRequired,
+        });
+
+        // Function to show captcha-specific error
+        const showCaptchaError = (message) => {
+            // Hide the general error message if shown
+            $(el_window).find('.login-error-msg').hide();
+            
+            // Show captcha-specific error
+            const captchaError = $(el_window).find('.captcha-error-msg');
+            captchaError.html(message);
+            captchaError.fadeIn();
+            
+            // Add visual indication of error to captcha container
+            $(captchaContainer).addClass('error');
+            $(captchaContainer).css('border', '1px solid #e74c3c');
+            $(captchaContainer).css('border-radius', '4px');
+            $(captchaContainer).css('padding', '10px');
+            
+            // Focus on the captcha input for better UX
+            setTimeout(() => {
+                const captchaInput = $(captchaContainer).find('.captcha-input');
+                if (captchaInput.length) {
+                    captchaInput.focus();
+                }
+            }, 100);
+        };
+
+        // Function to clear captcha errors
+        const clearCaptchaError = () => {
+            $(el_window).find('.captcha-error-msg').hide();
+            $(captchaContainer).removeClass('error');
+            $(captchaContainer).css('border', '');
+            $(captchaContainer).css('padding', '');
+        };
+
         $(el_window).find('.forgot-password-link').on('click', function(e){
             UIWindowRecoverPassword({
                 window_options: {
@@ -135,36 +193,105 @@ async function UIWindowLogin(options){
         })
 
         $(el_window).find('.login-btn').on('click', function(e){
+            // Prevent default button behavior (important for async requests)
+            e.preventDefault();
+            
+            // Clear previous error states
+            $(el_window).find('.login-error-msg').hide();
+            clearCaptchaError();
+
             const email_username = $(el_window).find('.email_or_username').val();
             const password = $(el_window).find('.password').val();
+            
+            // Basic validation for email/username and password
+            if(!email_username) {
+                $(el_window).find('.login-error-msg').html(i18n('email_or_username_required') || 'Email or username is required');
+                $(el_window).find('.login-error-msg').fadeIn();
+                return;
+            }
+            
+            if(!password) {
+                $(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
+                $(el_window).find('.login-error-msg').fadeIn();
+                return;
+            }
+            
+            // Get captcha token and answer if required
+            let captchaToken = null;
+            let captchaAnswer = null;
+            
+            if (captcha.isRequired()) {
+                captchaToken = captcha.getToken();
+                captchaAnswer = captcha.getAnswer();
+                
+                // Validate captcha if it's required
+                if (!captcha || !captchaContainer) {
+                    $(el_window).find('.login-error-msg').html(i18n('captcha_system_error') || 'Verification system error. Please refresh the page.');
+                    $(el_window).find('.login-error-msg').fadeIn();
+                    return;
+                }
+                
+                if (!captchaToken) {
+                    showCaptchaError(i18n('captcha_load_error') || 'Could not load verification code. Please refresh the page or try again later.');
+                    return;
+                }
+                
+                if (!captchaAnswer) {
+                    showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
+                    return;
+                }
+                
+                if (captchaAnswer.trim().length < 3) {
+                    showCaptchaError(i18n('captcha_too_short') || 'Verification code answer is too short.');
+                    return;
+                }
+                
+                if (captchaAnswer.trim().length > 12) {
+                    showCaptchaError(i18n('captcha_too_long') || 'Verification code answer is too long.');
+                    return;
+                }
+            }
+            
+            // Prepare data for the request
             let data;
-        
             if(window.is_email(email_username)){
                 data = JSON.stringify({ 
                     email: email_username, 
-                    password: password
-                })
-            }else{
+                    password: password,
+                    ...(captchaToken && captchaAnswer ? { 
+                        captchaToken: captchaToken,
+                        captchaAnswer: captchaAnswer 
+                    } : {})
+                });
+            } else {
                 data = JSON.stringify({ 
                     username: email_username, 
-                    password: password
-                })
+                    password: password,
+                    ...(captchaToken && captchaAnswer ? { 
+                        captchaToken: captchaToken,
+                        captchaAnswer: captchaAnswer 
+                    } : {})
+                });
             }
         
-            $(el_window).find('.login-error-msg').hide();
-        
             let headers = {};
             if(window.custom_headers)
                 headers = window.custom_headers;
     
+            // Disable the login button to prevent multiple submissions
+            $(el_window).find('.login-btn').prop('disabled', true);
+    
+            console.log('Sending login AJAX request with async: true');
             $.ajax({
                 url: window.gui_origin + "/login",
                 type: 'POST',
-                async: false,
+                async: true,
                 headers: headers,
                 contentType: "application/json",
                 data: data,				
                 success: async function (data){
+                    console.log('Login request successful');
+                    // Keep the button disabled on success since we're redirecting or closing
                     let p = Promise.resolve();
                     if ( data.next_step === 'otp' ) {
                         p = new TeePromise();
@@ -334,12 +461,96 @@ async function UIWindowLogin(options){
                     
                     if(options.reload_on_success){
                         window.onbeforeunload = null;
-                        window.location.replace('/');
+                        console.log('About to redirect, checking URL parameters:', window.location.search);
+                        // Replace with a clean URL to prevent password leakage
+                        const cleanUrl = window.location.origin + window.location.pathname;
+                        window.location.replace(cleanUrl);
                     }else
                         resolve(true);
                     $(el_window).close();
                 },
                 error: function (err){
+                    console.log('Login AJAX request error:', err.status, err.statusText);
+                    
+                    // First, ensure URL is clean in case of error (prevent password leakage)
+                    if (window.location.search && (
+                        window.location.search.includes('password=') || 
+                        window.location.search.includes('username=') || 
+                        window.location.search.includes('email=')
+                    )) {
+                        console.log('Cleaning sensitive data from URL');
+                        const cleanUrl = window.location.origin + window.location.pathname;
+                        history.replaceState({}, document.title, cleanUrl);
+                    }
+                    
+                    // Enable 'Log In' button
+                    $(el_window).find('.login-btn').prop('disabled', false);
+                    
+                    // Handle captcha-specific errors
+                    const errorText = err.responseText || '';
+                    const errorStatus = err.status || 0;
+                    
+                    // Try to parse error as JSON
+                    try {
+                        const errorJson = JSON.parse(errorText);
+                        
+                        // Check for specific error codes
+                        if (errorJson.code === 'captcha_required') {
+                            // If captcha is now required but wasn't before, update the component
+                            if (!captcha.isRequired()) {
+                                captcha.setRequired(true);
+                                showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
+                            } else {
+                                showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
+                            }
+                            return;
+                        } 
+                        
+                        if (errorJson.code === 'captcha_invalid' || errorJson.code === 'captcha_error') {
+                            showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
+                            // Refresh the captcha if it's invalid
+                            captcha.reset();
+                            return;
+                        }
+                        
+                        // If it's a message in the JSON, use that
+                        if (errorJson.message) {
+                            $(el_window).find('.login-error-msg').html(errorJson.message);
+                            $(el_window).find('.login-error-msg').fadeIn();
+                            return;
+                        }
+                    } catch (e) {
+                        // Not JSON, continue with text analysis
+                    }
+                    
+                    // Check for specific captcha errors using more robust detection for text responses
+                    if (
+                        errorText.includes('captcha_required') || 
+                        errorText.includes('Captcha verification required') ||
+                        (errorText.includes('captcha') && errorText.includes('required'))
+                    ) {
+                        // If captcha is now required but wasn't before, update the component
+                        if (!captcha.isRequired()) {
+                            captcha.setRequired(true);
+                            showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
+                        } else {
+                            showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
+                        }
+                        return;
+                    } 
+                    
+                    if (
+                        errorText.includes('captcha_invalid') || 
+                        errorText.includes('Invalid captcha') ||
+                        (errorText.includes('captcha') && (errorText.includes('invalid') || errorText.includes('incorrect')))
+                    ) {
+                        showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
+                        // Refresh the captcha if it's invalid
+                        captcha.reset();
+                        return;
+                    }
+                    
+                    // Fall back to original error handling
                     const $errorMessage = $(el_window).find('.login-error-msg');
                     if (err.status === 404) {
                         // Don't include the whole 404 page
@@ -372,6 +583,27 @@ async function UIWindowLogin(options){
         $(el_window).find('.login-form').on('submit', function(e){
             e.preventDefault();
             e.stopPropagation();
+            
+            // Instead of triggering the click event, process the login directly
+            const email_username = $(el_window).find('.email_or_username').val();
+            const password = $(el_window).find('.password').val();
+            
+            // Basic validation
+            if(!email_username) {
+                $(el_window).find('.login-error-msg').html(i18n('email_or_username_required') || 'Email or username is required');
+                $(el_window).find('.login-error-msg').fadeIn();
+                return false;
+            }
+            
+            if(!password) {
+                $(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
+                $(el_window).find('.login-error-msg').fadeIn();
+                return false;
+            }
+            
+            // Process login using the same function as the button click
+            $(el_window).find('.login-btn').click();
+            
             return false;
         })
 

+ 188 - 14
src/gui/src/UI/UIWindowSignup.js

@@ -21,15 +21,23 @@ import UIWindow from './UIWindow.js'
 import UIWindowLogin from './UIWindowLogin.js'
 import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'
 import check_password_strength from '../helpers/check_password_strength.js'
+import CaptchaView from './Components/CaptchaView.js'
+import { isCaptchaRequired } from '../helpers/captchaHelper.js'
 
 function UIWindowSignup(options){
     options = options ?? {};
-    options.reload_on_success = options.reload_on_success ?? false;
+    options.reload_on_success = options.reload_on_success ?? true;
     options.has_head = options.has_head ?? true;
     options.send_confirmation_code  = options.send_confirmation_code ?? false;
+    options.show_close_button = options.show_close_button ?? true;
 
     return new Promise(async (resolve) => {
         const internal_id = window.uuidv4();
+        
+        // Check if captcha is required for signup
+        const captchaRequired = await isCaptchaRequired('signup');
+        console.log('Signup captcha required:', captchaRequired);
+        
         let h = '';
         h += `<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">`;
             // logo
@@ -61,6 +69,10 @@ function UIWindowSignup(options){
                         h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
                         h += `<input id="password-${internal_id}" class="password" type="password" name="password" autocomplete="new-password" />`;
                     h += `</div>`;
+                    // captcha placeholder - will be replaced with actual captcha component
+                    h += `<div class="captcha-container"></div>`;
+                    // captcha-specific error message
+                    h += `<div class="captcha-error-msg" style="color: #e74c3c; font-size: 12px; margin-top: 5px; display: none;" aria-live="polite"></div>`;
                     // bot trap - if this value is submitted server will ignore the request
                     h += `<input type="text" name="p102xyzname" class="p102xyzname" value="">`;
 
@@ -118,6 +130,13 @@ function UIWindowSignup(options){
             }
         })
 
+        // Initialize the captcha component with the required state
+        const captchaContainer = $(el_window).find('.captcha-container')[0];
+        const captcha = CaptchaView({ 
+            container: captchaContainer,
+            required: captchaRequired
+        });
+
         $(el_window).find('.login-c2a-clickable').on('click', async function(e){
             $('.login-c2a-clickable').parents('.window').close();
             const login = await UIWindowLogin({
@@ -132,7 +151,44 @@ function UIWindowSignup(options){
                 resolve(true);
         })
 
+        // Function to show captcha-specific error
+        const showCaptchaError = (message) => {
+            // Hide the general error message if shown
+            $(el_window).find('.signup-error-msg').hide();
+            
+            // Show captcha-specific error
+            const captchaError = $(el_window).find('.captcha-error-msg');
+            captchaError.html(message);
+            captchaError.fadeIn();
+            
+            // Add visual indication of error to captcha container
+            $(captchaContainer).addClass('error');
+            $(captchaContainer).css('border', '1px solid #e74c3c');
+            $(captchaContainer).css('border-radius', '4px');
+            $(captchaContainer).css('padding', '10px');
+            
+            // Focus on the captcha input for better UX
+            setTimeout(() => {
+                const captchaInput = $(captchaContainer).find('.captcha-input');
+                if (captchaInput.length) {
+                    captchaInput.focus();
+                }
+            }, 100);
+        };
+
+        // Function to clear captcha errors
+        const clearCaptchaError = () => {
+            $(el_window).find('.captcha-error-msg').hide();
+            $(captchaContainer).removeClass('error');
+            $(captchaContainer).css('border', '');
+            $(captchaContainer).css('padding', '');
+        };
+
         $(el_window).find('.signup-btn').on('click', function(e){
+            // Clear previous error states
+            $(el_window).find('.signup-error-msg').hide();
+            clearCaptchaError();
+
             //Username
             let username = $(el_window).find('.username').val();
 
@@ -175,6 +231,46 @@ function UIWindowSignup(options){
                 return;
             }
 
+            // Get captcha token and answer if required
+            let captchaToken = null;
+            let captchaAnswer = null;
+            
+            if (captcha.isRequired()) {
+                captchaToken = captcha.getToken();
+                captchaAnswer = captcha.getAnswer();
+                
+                // Check if the captcha component is properly loaded
+                if (!captcha || !captchaContainer) {
+                    $(el_window).find('.signup-error-msg').html(i18n('captcha_system_error') || 'Verification system error. Please refresh the page.');
+                    $(el_window).find('.signup-error-msg').fadeIn();
+                    return;
+                }
+                
+                // Check if captcha token exists
+                if (!captchaToken) {
+                    showCaptchaError(i18n('captcha_load_error') || 'Could not load verification code. Please refresh the page or try again later.');
+                    return;
+                }
+                
+                // Check if the answer is provided
+                if (!captchaAnswer) {
+                    showCaptchaError(i18n('captcha_required'));
+                    return;
+                }
+                
+                // Check if answer meets minimum length requirement
+                if (captchaAnswer.trim().length < 3) {
+                    showCaptchaError(i18n('captcha_too_short') || 'Verification code answer is too short.');
+                    return;
+                }
+                
+                // Check if answer meets maximum length requirement
+                if (captchaAnswer.trim().length > 12) {
+                    showCaptchaError(i18n('captcha_too_long') || 'Verification code answer is too long.');
+                    return;
+                }
+            }
+            
             //xyzname
             let p102xyzname = $(el_window).find('.p102xyzname').val();
 
@@ -185,28 +281,37 @@ function UIWindowSignup(options){
             if(window.custom_headers)
                 headers = window.custom_headers;
 
+            // Include captcha in request only if required
+            const requestData = {
+                username: username,
+                referral_code: window.referral_code,
+                email: email,
+                password: password,
+                referrer: options.referrer ?? window.referrerStr,
+                send_confirmation_code: options.send_confirmation_code,
+                p102xyzname: p102xyzname,
+                ...(captchaToken && captchaAnswer ? {
+                    captchaToken: captchaToken,
+                    captchaAnswer: captchaAnswer
+                } : {})
+            };
+
             $.ajax({
                 url: window.gui_origin + "/signup",
                 type: 'POST',
                 async: true,
                 headers: headers,
                 contentType: "application/json",
-                data: JSON.stringify({ 
-                    username: username,
-                    referral_code: window.referral_code,
-                    email: email, 
-                    password: password,
-                    referrer: options.referrer ?? window.referrerStr,
-                    send_confirmation_code: options.send_confirmation_code,
-                    p102xyzname: p102xyzname,
-                }),
+                data: JSON.stringify(requestData),
                 success: async function (data){
                     window.update_auth_data(data.token, data.user)
                     
                     //send out the login event
                     if(options.reload_on_success){
                         window.onbeforeunload = null;
-                        window.location.replace('/');
+                        // Replace with a clean URL to prevent sensitive data leakage
+                        const cleanUrl = window.location.origin + window.location.pathname;
+                        window.location.replace(cleanUrl);
                     }else if(options.send_confirmation_code){
                         $(el_window).close();
                         let is_verified = await UIWindowEmailConfirmationRequired({stay_on_top: true, has_head: true});
@@ -216,11 +321,80 @@ function UIWindowSignup(options){
                     }
                 },
                 error: function (err){
-                    $(el_window).find('.signup-error-msg').html(err.responseText);
-                    $(el_window).find('.signup-error-msg').fadeIn();
                     // re-enable 'Create Account' button so user can try again
                     $(el_window).find('.signup-btn').prop('disabled', false);
-                }
+
+                    // Process error response
+                    const errorText = err.responseText || '';
+                    const errorStatus = err.status || 0;
+                    
+                    // Handle JSON error response
+                    try {
+                        // Try to parse error as JSON
+                        const errorJson = JSON.parse(errorText);
+                        
+                        // Check for specific error codes
+                        if (errorJson.code === 'captcha_required') {
+                            // If captcha is now required but wasn't before, update the component
+                            if (!captcha.isRequired()) {
+                                captcha.setRequired(true);
+                                showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
+                            } else {
+                                showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
+                            }
+                            return;
+                        } 
+                        
+                        if (errorJson.code === 'captcha_invalid' || errorJson.code === 'captcha_error') {
+                            showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
+                            // Refresh the captcha if it's invalid
+                            captcha.reset();
+                            return;
+                        }
+                        
+                        // If it's a message in the JSON, use that
+                        if (errorJson.message) {
+                            $(el_window).find('.signup-error-msg').html(errorJson.message);
+                            $(el_window).find('.signup-error-msg').fadeIn();
+                            return;
+                        }
+                    } catch (e) {
+                        // Not JSON, continue with text analysis
+                    }
+                    
+                    // Check for specific captcha errors using more robust detection for text responses
+                    if (
+                        errorText.includes('captcha_required') || 
+                        errorText.includes('Captcha verification required') ||
+                        (errorText.includes('captcha') && errorText.includes('required'))
+                    ) {
+                        showCaptchaError(i18n('captcha_required'));
+                        return;
+                    } 
+                    
+                    if (
+                        errorText.includes('captcha_invalid') || 
+                        errorText.includes('Invalid captcha') ||
+                        (errorText.includes('captcha') && (errorText.includes('invalid') || errorText.includes('incorrect')))
+                    ) {
+                        showCaptchaError(i18n('captcha_invalid'));
+                        // Refresh the captcha if it's invalid
+                        captcha.reset();
+                        return;
+                    }
+                    
+                    // Handle timeout specifically
+                    if (errorJson?.code === 'response_timeout' || errorText.includes('timeout')) {
+                        $(el_window).find('.signup-error-msg').html(i18n('server_timeout') || 'The server took too long to respond. Please try again.');
+                        $(el_window).find('.signup-error-msg').fadeIn();
+                        return;
+                    }
+
+                    // Default general error handling
+                    $(el_window).find('.signup-error-msg').html(errorText || i18n('signup_error') || 'An error occurred during signup. Please try again.');
+                    $(el_window).find('.signup-error-msg').fadeIn();
+                },
+                timeout: 30000 // Add a reasonable timeout
             });
         })
 

+ 87 - 0
src/gui/src/helpers/captchaHelper.js

@@ -0,0 +1,87 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/**
+ * Cache for captcha requirements to avoid repeated API calls
+ */
+let captchaRequirementsCache = null;
+
+/**
+ * Checks if captcha is required for a specific action
+ * 
+ * This function first checks GUI parameters, then falls back to the /whoarewe endpoint
+ * 
+ * @param {string} actionType - The type of action (e.g., 'login', 'signup')
+ * @returns {Promise<boolean>} - Whether captcha is required for this action
+ */
+async function isCaptchaRequired(actionType) {
+    console.log('CAPTCHA DIAGNOSTIC (Client): isCaptchaRequired called for', actionType);
+    
+    // Check if we have the info in GUI parameters
+    if (window.gui_params?.captchaRequired?.[actionType] !== undefined) {
+        console.log(`CAPTCHA DIAGNOSTIC (Client): Requirement for ${actionType} from GUI params:`, window.gui_params.captchaRequired[actionType]);
+        console.log('CAPTCHA DIAGNOSTIC (Client): Full gui_params.captchaRequired =', JSON.stringify(window.gui_params.captchaRequired));
+        return window.gui_params.captchaRequired[actionType];
+    }
+
+    // If not in GUI params, check the cache
+    if (captchaRequirementsCache && captchaRequirementsCache.captchaRequired?.[actionType] !== undefined) {
+        console.log(`CAPTCHA DIAGNOSTIC (Client): Requirement for ${actionType} from cache:`, captchaRequirementsCache.captchaRequired[actionType]);
+        console.log('CAPTCHA DIAGNOSTIC (Client): Full cache =', JSON.stringify(captchaRequirementsCache.captchaRequired));
+        return captchaRequirementsCache.captchaRequired[actionType];
+    }
+
+    // If not in cache, fetch from the /whoarewe endpoint
+    try {
+        console.log(`CAPTCHA DIAGNOSTIC (Client): Fetching from /whoarewe for ${actionType}`);
+        const response = await fetch(window.api_origin + '/whoarewe');
+        
+        if (!response.ok) {
+            console.warn(`CAPTCHA DIAGNOSTIC (Client): Failed to get requirements: ${response.status}`);
+            return true; // Default to requiring captcha if we can't determine
+        }
+        
+        const data = await response.json();
+        console.log(`CAPTCHA DIAGNOSTIC (Client): /whoarewe response:`, data);
+        
+        // Cache the result
+        captchaRequirementsCache = data;
+        
+        // Return the requirement or default to true if not specified
+        const result = data.captchaRequired?.[actionType] ?? true;
+        console.log(`CAPTCHA DIAGNOSTIC (Client): Final result for ${actionType}:`, result);
+        return result;
+    } catch (error) {
+        console.error('CAPTCHA DIAGNOSTIC (Client): Error checking requirements:', error);
+        return true; // Default to requiring captcha on error
+    }
+}
+
+/**
+ * Invalidates the captcha requirements cache
+ * This is useful when the requirements might have changed
+ */
+function invalidateCaptchaRequirementsCache() {
+    captchaRequirementsCache = null;
+}
+
+export { 
+    isCaptchaRequired,
+    invalidateCaptchaRequirementsCache
+}; 

+ 17 - 0
src/gui/src/i18n/translations/en.js

@@ -410,6 +410,23 @@ const en = {
         'billing.expanded': 'Expanded',
         'billing.accelerated': 'Accelerated',
         'billing.enjoy_msg': 'Enjoy %% of Cloud Storage plus other benefits.',
+        
+        // Captcha related strings
+        'captcha_verification': 'Verification Code',
+        'enter_captcha_text': 'Enter the text you see above',
+        'refresh_captcha': 'Get a new code',
+        'captcha_case_sensitive': 'Please enter the characters exactly as they appear. Case sensitive.',
+        'captcha_required': 'Please complete the verification code.',
+        'captcha_now_required': 'Verification is now required. Please complete the verification below.',
+        'captcha_invalid': 'Incorrect verification code. Please try again.',
+        'captcha_expired': 'Verification code has expired. Please try a new one.',
+        'captcha_system_error': 'Verification system error. Please refresh the page.',
+        'captcha_load_error': 'Could not load verification code. Please refresh the page or try again later.',
+        'captcha_too_short': 'Verification code answer is too short.',
+        'captcha_too_long': 'Verification code answer is too long.',
+        'too_many_attempts': 'Too many attempts. Please try again later.',
+        'server_timeout': 'The server took too long to respond. Please try again.',
+        'signup_error': 'An error occurred during signup. Please try again.'
     }
 };
 

+ 15 - 1
src/gui/src/index.js

@@ -20,6 +20,8 @@
 
 window.puter_gui_enabled = true;
 
+import { isCaptchaRequired } from './helpers/captchaHelper.js';
+
 /**
  * Initializes and configures the GUI (Graphical User Interface) settings based on the provided options.
  *
@@ -46,7 +48,7 @@ window.puter_gui_enabled = true;
  * });
  */
 
-window.gui = async function(options){
+window.gui = async (options) => {
     options = options ?? {};
     // app_origin is deprecated, use gui_origin instead
     window.gui_params = options;
@@ -59,6 +61,18 @@ window.gui = async function(options){
     window.disable_temp_users = options.disable_temp_users ?? false;
     window.co_isolation_enabled = options.co_isolation_enabled;
 
+    // Preload captcha requirements if not already in GUI parameters
+    if (!options.captchaRequired) {
+        // Start loading in the background, but don't await
+        // This way we don't delay the GUI initialization
+        Promise.all([
+            isCaptchaRequired('login'),
+            isCaptchaRequired('signup')
+        ]).catch(err => {
+            console.warn('Failed to preload captcha requirements:', err);
+        });
+    }
+
     // DEV: Load the initgui.js file if we are in development mode
     if(!window.gui_env || window.gui_env === "dev"){
         await window.loadScript('/sdk/puter.dev.js');

+ 8 - 0
src/gui/src/initgui.js

@@ -1426,3 +1426,11 @@ $(document).on('contextmenu', '.disable-context-menu', function(e){
 
 // util/desktop.js
 window.privacy_aware_path = privacy_aware_path({ window });
+
+$(window).on('system-logout-event', function(){
+    // Clear cookie
+    document.cookie = 'puter=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
+    // Redirect to clean URL without any query parameters
+    const cleanUrl = window.location.origin + window.location.pathname;
+    window.location.replace(cleanUrl);
+});

+ 188 - 0
src/gui/test/components/CaptchaView.test.js

@@ -0,0 +1,188 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import { describe, it, beforeEach, afterEach } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import jsdom from 'jsdom';
+
+const { JSDOM } = jsdom;
+
+// Mock the DOM environment
+const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
+global.window = dom.window;
+global.document = dom.window.document;
+global.HTMLElement = dom.window.HTMLElement;
+global.customElements = dom.window.customElements;
+
+// Mock the captchaHelper
+const captchaHelper = {
+    isCaptchaRequired: sinon.stub()
+};
+
+// Mock the grecaptcha object
+global.grecaptcha = {
+    ready: sinon.stub().callsFake(cb => cb()),
+    execute: sinon.stub().resolves('mock-token'),
+    render: sinon.stub().returns('captcha-widget-id')
+};
+
+// Import the module under test (mock import)
+const CaptchaView = {
+    prototype: {
+        connectedCallback: sinon.stub(),
+        disconnectedCallback: sinon.stub(),
+        setRequired: sinon.stub(),
+        isRequired: sinon.stub(),
+        getValue: sinon.stub(),
+        reset: sinon.stub()
+    }
+};
+
+describe('CaptchaView', () => {
+    let captchaElement;
+    
+    beforeEach(() => {
+        // Create a mock CaptchaView element
+        captchaElement = {
+            ...CaptchaView.prototype,
+            getAttribute: sinon.stub(),
+            setAttribute: sinon.stub(),
+            removeAttribute: sinon.stub(),
+            appendChild: sinon.stub(),
+            querySelector: sinon.stub(),
+            style: {},
+            dataset: {},
+            captchaWidgetId: null,
+            captchaContainer: document.createElement('div')
+        };
+        
+        // Reset stubs
+        Object.values(CaptchaView.prototype).forEach(stub => {
+            if (typeof stub.reset === 'function') stub.reset();
+        });
+        
+        captchaHelper.isCaptchaRequired.reset();
+        grecaptcha.ready.reset();
+        grecaptcha.execute.reset();
+        grecaptcha.render.reset();
+    });
+    
+    describe('setRequired', () => {
+        it('should show captcha when required is true', () => {
+            // Setup
+            captchaElement.setRequired.callsFake(function(required) {
+                this.required = required;
+                if (required) {
+                    this.style.display = 'block';
+                } else {
+                    this.style.display = 'none';
+                }
+            });
+            
+            // Test
+            captchaElement.setRequired(true);
+            
+            // Assert
+            expect(captchaElement.required).to.be.true;
+            expect(captchaElement.style.display).to.equal('block');
+        });
+        
+        it('should hide captcha when required is false', () => {
+            // Setup
+            captchaElement.setRequired.callsFake(function(required) {
+                this.required = required;
+                if (required) {
+                    this.style.display = 'block';
+                } else {
+                    this.style.display = 'none';
+                }
+            });
+            
+            // Test
+            captchaElement.setRequired(false);
+            
+            // Assert
+            expect(captchaElement.required).to.be.false;
+            expect(captchaElement.style.display).to.equal('none');
+        });
+    });
+    
+    describe('isRequired', () => {
+        it('should return the current required state', () => {
+            // Setup
+            captchaElement.required = true;
+            captchaElement.isRequired.callsFake(function() {
+                return this.required;
+            });
+            
+            // Test & Assert
+            expect(captchaElement.isRequired()).to.be.true;
+            
+            // Change state
+            captchaElement.required = false;
+            
+            // Test & Assert again
+            expect(captchaElement.isRequired()).to.be.false;
+        });
+    });
+    
+    describe('getValue', () => {
+        it('should return null when captcha is not required', () => {
+            // Setup
+            captchaElement.required = false;
+            captchaElement.getValue.callsFake(function() {
+                return this.required ? 'mock-token' : null;
+            });
+            
+            // Test & Assert
+            expect(captchaElement.getValue()).to.be.null;
+        });
+        
+        it('should return token when captcha is required', () => {
+            // Setup
+            captchaElement.required = true;
+            captchaElement.getValue.callsFake(function() {
+                return this.required ? 'mock-token' : null;
+            });
+            
+            // Test & Assert
+            expect(captchaElement.getValue()).to.equal('mock-token');
+        });
+    });
+    
+    describe('reset', () => {
+        it('should reset the captcha widget when it exists', () => {
+            // Setup
+            captchaElement.captchaWidgetId = 'captcha-widget-id';
+            global.grecaptcha.reset = sinon.stub();
+            captchaElement.reset.callsFake(function() {
+                if (this.captchaWidgetId) {
+                    grecaptcha.reset(this.captchaWidgetId);
+                }
+            });
+            
+            // Test
+            captchaElement.reset();
+            
+            // Assert
+            expect(grecaptcha.reset.calledWith('captcha-widget-id')).to.be.true;
+        });
+    });
+}); 

+ 215 - 0
src/gui/test/helpers/captchaHelper.test.js

@@ -0,0 +1,215 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import { describe, it, beforeEach, afterEach } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+// Mock the fetch API and global window object for testing
+global.window = {
+    api_origin: 'https://test-api.puter.com',
+    gui_params: {}
+};
+global.fetch = sinon.stub();
+global.console = {
+    log: sinon.stub(),
+    warn: sinon.stub(),
+    error: sinon.stub()
+};
+
+// Import the module under test
+import { isCaptchaRequired, invalidateCaptchaRequirementsCache } from '../../src/helpers/captchaHelper.js';
+
+describe('captchaHelper', () => {
+    let fetchStub;
+    
+    beforeEach(() => {
+        // Reset stubs before each test
+        fetchStub = global.fetch;
+        fetchStub.reset();
+        
+        // Reset the window object
+        global.window.gui_params = {};
+    });
+    
+    afterEach(() => {
+        // Reset any cached data between tests
+        invalidateCaptchaRequirementsCache();
+    });
+    
+    describe('isCaptchaRequired', () => {
+        it('should use GUI parameters if available', async () => {
+            // Setup
+            global.window.gui_params = {
+                captchaRequired: {
+                    login: true,
+                    signup: false
+                }
+            };
+            
+            // Test
+            const loginRequired = await isCaptchaRequired('login');
+            const signupRequired = await isCaptchaRequired('signup');
+            
+            // Assert
+            expect(loginRequired).to.be.true;
+            expect(signupRequired).to.be.false;
+            expect(fetchStub.called).to.be.false; // Fetch should not be called
+        });
+        
+        it('should fetch from API if GUI parameters are not available', async () => {
+            // Setup
+            const apiResponse = {
+                captchaRequired: {
+                    login: false,
+                    signup: true
+                }
+            };
+            
+            fetchStub.resolves({
+                ok: true,
+                json: () => Promise.resolve(apiResponse)
+            });
+            
+            // Test
+            const loginRequired = await isCaptchaRequired('login');
+            
+            // Assert
+            expect(loginRequired).to.be.false;
+            expect(fetchStub.calledOnce).to.be.true;
+            expect(fetchStub.firstCall.args[0]).to.equal('https://test-api.puter.com/whoarewe');
+        });
+        
+        it('should cache API responses for subsequent calls', async () => {
+            // Setup
+            const apiResponse = {
+                captchaRequired: {
+                    login: true,
+                    signup: false
+                }
+            };
+            
+            fetchStub.resolves({
+                ok: true,
+                json: () => Promise.resolve(apiResponse)
+            });
+            
+            // Test - first call should use the API
+            const firstLoginRequired = await isCaptchaRequired('login');
+            
+            // Second call should use the cache
+            const secondLoginRequired = await isCaptchaRequired('login');
+            
+            // Assert
+            expect(firstLoginRequired).to.be.true;
+            expect(secondLoginRequired).to.be.true;
+            expect(fetchStub.calledOnce).to.be.true; // Fetch should only be called once
+        });
+        
+        it('should handle API errors and default to requiring captcha', async () => {
+            // Setup
+            fetchStub.rejects(new Error('Network error'));
+            
+            // Test
+            const loginRequired = await isCaptchaRequired('login');
+            
+            // Assert
+            expect(loginRequired).to.be.true; // Should default to true on error
+            expect(fetchStub.calledOnce).to.be.true;
+        });
+        
+        it('should handle non-200 API responses and default to requiring captcha', async () => {
+            // Setup
+            fetchStub.resolves({
+                ok: false,
+                status: 500
+            });
+            
+            // Test
+            const loginRequired = await isCaptchaRequired('login');
+            
+            // Assert
+            expect(loginRequired).to.be.true; // Should default to true on error
+            expect(fetchStub.calledOnce).to.be.true;
+        });
+        
+        it('should handle missing action type in response and default to requiring captcha', async () => {
+            // Setup
+            const apiResponse = {
+                captchaRequired: {
+                    // login is missing
+                    signup: false
+                }
+            };
+            
+            fetchStub.resolves({
+                ok: true,
+                json: () => Promise.resolve(apiResponse)
+            });
+            
+            // Test
+            const loginRequired = await isCaptchaRequired('login');
+            
+            // Assert
+            expect(loginRequired).to.be.true; // Should default to true if not specified
+            expect(fetchStub.calledOnce).to.be.true;
+        });
+    });
+    
+    describe('invalidateCaptchaRequirementsCache', () => {
+        it('should invalidate the cache and force a new API call', async () => {
+            // Setup - first API call
+            const firstApiResponse = {
+                captchaRequired: {
+                    login: true
+                }
+            };
+            
+            fetchStub.resolves({
+                ok: true,
+                json: () => Promise.resolve(firstApiResponse)
+            });
+            
+            // First call to cache the result
+            await isCaptchaRequired('login');
+            
+            // Setup - second API call with different response
+            const secondApiResponse = {
+                captchaRequired: {
+                    login: false
+                }
+            };
+            
+            fetchStub.resolves({
+                ok: true,
+                json: () => Promise.resolve(secondApiResponse)
+            });
+            
+            // Invalidate the cache
+            invalidateCaptchaRequirementsCache();
+            
+            // Test - this should now make a new API call
+            const loginRequired = await isCaptchaRequired('login');
+            
+            // Assert
+            expect(loginRequired).to.be.false; // Should get the new value
+            expect(fetchStub.calledTwice).to.be.true; // Fetch should be called twice
+        });
+    });
+}); 

+ 2862 - 0
test/integration/package-lock.json

@@ -0,0 +1,2862 @@
+{
+  "name": "puter-integration-tests",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "puter-integration-tests",
+      "version": "1.0.0",
+      "devDependencies": {
+        "body-parser": "^1.20.2",
+        "chai": "^4.3.7",
+        "express": "^4.18.2",
+        "jsdom": "^21.1.0",
+        "mocha": "^10.2.0",
+        "sinon": "^15.2.0",
+        "supertest": "^6.3.3"
+      }
+    },
+    "node_modules/@sinonjs/commons": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+      "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/commons/node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@sinonjs/fake-timers": {
+      "version": "10.3.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+      "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.0"
+      }
+    },
+    "node_modules/@sinonjs/samsam": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz",
+      "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.1",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.1.0"
+      }
+    },
+    "node_modules/@sinonjs/text-encoding": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz",
+      "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==",
+      "dev": true,
+      "license": "(Unlicense OR Apache-2.0)"
+    },
+    "node_modules/@tootallnate/once": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
+      "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/abab": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
+      "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
+      "deprecated": "Use your platform's native atob() and btoa() methods instead",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.14.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
+      "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-globals": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
+      "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.1.0",
+        "acorn-walk": "^8.0.2"
+      }
+    },
+    "node_modules/acorn-walk": {
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+      "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.11.0"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/agent-base/node_modules/debug": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/agent-base/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ansi-colors": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+      "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/asap": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+      "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/assertion-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.3",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+      "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.4.24",
+        "on-finished": "2.4.1",
+        "qs": "6.13.0",
+        "raw-body": "2.5.2",
+        "type-is": "~1.6.18",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browser-stdout": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+      "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/camelcase": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/chai": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+      "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^1.1.0",
+        "check-error": "^1.0.3",
+        "deep-eql": "^4.1.3",
+        "get-func-name": "^2.0.2",
+        "loupe": "^2.3.6",
+        "pathval": "^1.1.1",
+        "type-detect": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chalk/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+      "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "get-func-name": "^2.0.2"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/component-emitter": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
+      "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+      "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookiejar": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
+      "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cssstyle": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz",
+      "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "rrweb-cssom": "^0.6.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/data-urls": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz",
+      "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "abab": "^2.0.6",
+        "whatwg-mimetype": "^3.0.0",
+        "whatwg-url": "^12.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/decamelize": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+      "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
+      "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/deep-eql": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+      "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "type-detect": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dezalgo": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
+      "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "asap": "^2.0.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/diff": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+      "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/domexception": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
+      "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "webidl-conversions": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/escodegen": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+      "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esprima": "^4.0.1",
+        "estraverse": "^5.2.0",
+        "esutils": "^2.0.2"
+      },
+      "bin": {
+        "escodegen": "bin/escodegen.js",
+        "esgenerate": "bin/esgenerate.js"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "optionalDependencies": {
+        "source-map": "~0.6.1"
+      }
+    },
+    "node_modules/esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "bin": {
+        "esparse": "bin/esparse.js",
+        "esvalidate": "bin/esvalidate.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.21.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+      "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "1.20.3",
+        "content-disposition": "0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "0.7.1",
+        "cookie-signature": "1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "1.3.1",
+        "fresh": "0.5.2",
+        "http-errors": "2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "6.13.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "0.19.0",
+        "serve-static": "1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/fast-safe-stringify": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+      "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+      "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "2.0.1",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "bin": {
+        "flat": "cli.js"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
+      "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/formidable": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz",
+      "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dezalgo": "^1.0.4",
+        "hexoid": "^1.0.0",
+        "once": "^1.4.0",
+        "qs": "^6.11.0"
+      },
+      "funding": {
+        "url": "https://ko-fi.com/tunnckoCore/commissions"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-func-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+      "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dev": true,
+      "license": "MIT",
+      "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"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/glob": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
+      "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
+      "deprecated": "Glob versions prior to v9 are no longer supported",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^5.0.1",
+        "once": "^1.3.0"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/hexoid": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
+      "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
+      "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-encoding": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+      "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "depd": "2.0.0",
+        "inherits": "2.0.4",
+        "setprototypeof": "1.2.0",
+        "statuses": "2.0.1",
+        "toidentifier": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@tootallnate/once": "2",
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/http-proxy-agent/node_modules/debug": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/http-proxy-agent/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/https-proxy-agent/node_modules/debug": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/https-proxy-agent/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsdom": {
+      "version": "21.1.2",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.2.tgz",
+      "integrity": "sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "abab": "^2.0.6",
+        "acorn": "^8.8.2",
+        "acorn-globals": "^7.0.0",
+        "cssstyle": "^3.0.0",
+        "data-urls": "^4.0.0",
+        "decimal.js": "^10.4.3",
+        "domexception": "^4.0.0",
+        "escodegen": "^2.0.0",
+        "form-data": "^4.0.0",
+        "html-encoding-sniffer": "^3.0.0",
+        "http-proxy-agent": "^5.0.0",
+        "https-proxy-agent": "^5.0.1",
+        "is-potential-custom-element-name": "^1.0.1",
+        "nwsapi": "^2.2.4",
+        "parse5": "^7.1.2",
+        "rrweb-cssom": "^0.6.0",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^4.1.2",
+        "w3c-xmlserializer": "^4.0.0",
+        "webidl-conversions": "^7.0.0",
+        "whatwg-encoding": "^2.0.0",
+        "whatwg-mimetype": "^3.0.0",
+        "whatwg-url": "^12.0.1",
+        "ws": "^8.13.0",
+        "xml-name-validator": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "canvas": "^2.5.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/just-extend": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
+      "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+      "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/loupe": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+      "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "get-func-name": "^2.0.1"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/mocha": {
+      "version": "10.8.2",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz",
+      "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-colors": "^4.1.3",
+        "browser-stdout": "^1.3.1",
+        "chokidar": "^3.5.3",
+        "debug": "^4.3.5",
+        "diff": "^5.2.0",
+        "escape-string-regexp": "^4.0.0",
+        "find-up": "^5.0.0",
+        "glob": "^8.1.0",
+        "he": "^1.2.0",
+        "js-yaml": "^4.1.0",
+        "log-symbols": "^4.1.0",
+        "minimatch": "^5.1.6",
+        "ms": "^2.1.3",
+        "serialize-javascript": "^6.0.2",
+        "strip-json-comments": "^3.1.1",
+        "supports-color": "^8.1.1",
+        "workerpool": "^6.5.1",
+        "yargs": "^16.2.0",
+        "yargs-parser": "^20.2.9",
+        "yargs-unparser": "^2.0.0"
+      },
+      "bin": {
+        "_mocha": "bin/_mocha",
+        "mocha": "bin/mocha.js"
+      },
+      "engines": {
+        "node": ">= 14.0.0"
+      }
+    },
+    "node_modules/mocha/node_modules/debug": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mocha/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/nise": {
+      "version": "5.1.9",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz",
+      "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.0",
+        "@sinonjs/fake-timers": "^11.2.2",
+        "@sinonjs/text-encoding": "^0.7.2",
+        "just-extend": "^6.2.0",
+        "path-to-regexp": "^6.2.1"
+      }
+    },
+    "node_modules/nise/node_modules/@sinonjs/fake-timers": {
+      "version": "11.3.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz",
+      "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.1"
+      }
+    },
+    "node_modules/nise/node_modules/path-to-regexp": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+      "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/nwsapi": {
+      "version": "2.2.18",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.18.tgz",
+      "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "7.2.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
+      "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^4.5.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+      "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/psl": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+      "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/lupomontero"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.13.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+      "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.0.6"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/querystringify": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+      "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/rrweb-cssom": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz",
+      "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+      "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.0",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+      "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "0.5.2",
+        "http-errors": "2.0.0",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "2.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/serialize-javascript": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+      "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.2",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+      "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "0.19.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dev": true,
+      "license": "MIT",
+      "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"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/sinon": {
+      "version": "15.2.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz",
+      "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==",
+      "deprecated": "16.1.1",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.0",
+        "@sinonjs/fake-timers": "^10.3.0",
+        "@sinonjs/samsam": "^8.0.0",
+        "diff": "^5.1.0",
+        "nise": "^5.1.4",
+        "supports-color": "^7.2.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/sinon"
+      }
+    },
+    "node_modules/sinon/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+      "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/superagent": {
+      "version": "8.1.2",
+      "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
+      "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
+      "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "component-emitter": "^1.3.0",
+        "cookiejar": "^2.1.4",
+        "debug": "^4.3.4",
+        "fast-safe-stringify": "^2.1.1",
+        "form-data": "^4.0.0",
+        "formidable": "^2.1.2",
+        "methods": "^1.1.2",
+        "mime": "2.6.0",
+        "qs": "^6.11.0",
+        "semver": "^7.3.8"
+      },
+      "engines": {
+        "node": ">=6.4.0 <13 || >=14"
+      }
+    },
+    "node_modules/superagent/node_modules/debug": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/superagent/node_modules/mime": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+      "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/superagent/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/supertest": {
+      "version": "6.3.4",
+      "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
+      "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "methods": "^1.1.2",
+        "superagent": "^8.1.2"
+      },
+      "engines": {
+        "node": ">=6.4.0"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tough-cookie": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+      "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "psl": "^1.1.33",
+        "punycode": "^2.1.1",
+        "universalify": "^0.2.0",
+        "url-parse": "^1.5.3"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
+      "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/type-detect": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+      "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/universalify": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+      "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/url-parse": {
+      "version": "1.5.10",
+      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "querystringify": "^2.1.1",
+        "requires-port": "^1.0.0"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
+      "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
+      "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+      "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz",
+      "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^4.1.1",
+        "webidl-conversions": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/workerpool": {
+      "version": "6.5.1",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
+      "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "8.18.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
+      "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "20.2.9",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
+      "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-unparser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+      "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "camelcase": "^6.0.0",
+        "decamelize": "^4.0.0",
+        "flat": "^5.0.2",
+        "is-plain-obj": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  }
+}

+ 20 - 0
test/integration/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "puter-integration-tests",
+  "version": "1.0.0",
+  "description": "Integration tests for Puter",
+  "main": "index.js",
+  "scripts": {
+    "test": "mocha captcha/**/*.test.js",
+    "test:auth": "mocha captcha/authentication-flow.test.js",
+    "test:ui": "mocha captcha/ui-behavior.test.js"
+  },
+  "devDependencies": {
+    "chai": "^4.3.7",
+    "express": "^4.18.2",
+    "jsdom": "^21.1.0",
+    "mocha": "^10.2.0",
+    "sinon": "^15.2.0",
+    "body-parser": "^1.20.2",
+    "supertest": "^6.3.3"
+  }
+} 

Some files were not shown because too many files changed in this diff