diff --git a/.gitignore b/.gitignore index 37d7e734..262b15e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules .env +.playwright-mcp/ +playwright-report/ +test-results/ diff --git a/create-a-container/README.md b/create-a-container/README.md index 4c135ef0..9af6149a 100644 --- a/create-a-container/README.md +++ b/create-a-container/README.md @@ -1,624 +1,140 @@ # Create-a-Container -A web application for managing LXC container creation, configuration, and lifecycle on Proxmox VE infrastructure. Provides a user-friendly interface and REST API for container management with automated database tracking and nginx reverse proxy configuration generation. +Web application for managing LXC containers on Proxmox VE with a user-friendly interface and REST API. -## Data Model - -```mermaid -erDiagram - Node ||--o{ Container : "hosts" - Container ||--o{ Service : "exposes" - - Node { - int id PK - string name UK "Proxmox node name" - string apiUrl "Proxmox API URL" - boolean tlsVerify "Verify TLS certificates" - datetime createdAt - datetime updatedAt - } - - Container { - int id PK - string hostname UK "FQDN hostname" - string username "Owner username" - string status "pending,creating,running,failed" - string template "Template name" - int creationJobId FK "References Job" - int nodeId FK "References Node" - int containerId UK "Proxmox VMID" - string macAddress UK "MAC address (nullable)" - string ipv4Address UK "IPv4 address (nullable)" - string aiContainer "Node type flag" - datetime createdAt - datetime updatedAt - } - - Service { - int id PK - int containerId FK "References Container" - enum type "tcp, udp, or http" - int internalPort "Port inside container" - int externalPort "External port (tcp/udp only)" - boolean tls "TLS enabled (tcp only)" - string externalHostname UK "Public hostname (http only)" - datetime createdAt - datetime updatedAt - } -``` - -**Key Constraints:** -- `(Node.name)` - Unique -- `(Container.hostname)` - Unique -- `(Container.nodeId, Container.containerId)` - Unique (same VMID can exist on different nodes) -- `(Service.externalHostname)` - Unique when type='http' -- `(Service.type, Service.externalPort)` - Unique when type='tcp' or type='udp' +## Quick Start (Local Development) -## Features - -- **User Authentication** - Proxmox VE authentication integration -- **Container Management** - Create, list, and track LXC containers -- **Docker/OCI Support** - Pull and deploy containers from Docker Hub, GHCR, or any OCI registry -- **Service Registry** - Track HTTP/TCP/UDP services running on containers -- **Dynamic Nginx Config** - Generate nginx reverse proxy configurations on-demand -- **Real-time Progress** - SSE (Server-Sent Events) for container creation progress -- **User Registration** - Self-service account request system with email notifications -- **Rate Limiting** - Protection against abuse (100 requests per 15 minutes) - -## Prerequisites - -### System Requirements -- **Node.js** 18.x or higher -- **PostgreSQL** 16 or higher -- **Proxmox VE** cluster with API access -- **SMTP server** for email notifications (optional) - -### Services ```bash -# Install Node.js (Debian/Ubuntu) -curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -sudo apt-get install -y nodejs - -# Install PostgreSQL -sudo apt-get install postgresql -y +cd create-a-container +npm install +npx sequelize-cli db:migrate +npx sequelize-cli db:seed:all +node server.js ``` -## Installation - -### 1. Clone Repository -```bash -cd /opt -sudo git clone https://github.com/mieweb/opensource-server.git -cd opensource-server/create-a-container -``` +Open **http://localhost:3000** — the login page shows "No users exist yet." -### 2. Install Dependencies -```bash -npm install -``` +### First User = Admin -### 3. Database Setup +1. Click **Register** and create an account +2. Log in immediately (first user is auto-approved as admin) +3. Subsequent users require admin approval or email invite -#### Create Database and User -```sql -CREATE DATABASE opensource_containers CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER 'container_manager'@'localhost' IDENTIFIED BY 'secure_password_here'; -GRANT ALL PRIVILEGES ON opensource_containers.* TO 'container_manager'@'localhost'; -FLUSH PRIVILEGES; -``` +### Reset Database -#### Run Migrations ```bash -npm run db:migrate +rm data/database.sqlite +npx sequelize-cli db:migrate +npx sequelize-cli db:seed:all ``` -This creates the following tables: -- `Containers` - Container records (hostname, IP, MAC, OS, etc.) -- `Services` - Service mappings (ports, protocols, hostnames) +## Features + +- **Container Management** — Create, list, and track LXC containers +- **Docker/OCI Support** — Deploy from Docker Hub, GHCR, or any OCI registry +- **Service Registry** — Track HTTP/TCP/UDP services per container +- **Dynamic Nginx Config** — Auto-generate reverse proxy configurations +- **User Management** — Self-service registration with admin approval +- **Push Notification 2FA** — Optional two-factor authentication -### 4. Configuration +## Configuration -Create a `.env` file in the `create-a-container` directory: +SQLite is used by default for local development. For production, create a `.env` file: ```bash -# Database Configuration +# PostgreSQL (production) +DATABASE_DIALECT=postgres POSTGRES_HOST=localhost POSTGRES_USER=cluster_manager -POSTGRES_PASSWORD=secure_password_here +POSTGRES_PASSWORD=secure_password POSTGRES_DATABASE=cluster_manager -DATABASE_DIALECT=postgres -# Session Configuration -SESSION_SECRET=generate_random_secret_here +# Session (required for production) +SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") -# Application NODE_ENV=production ``` -#### Generate Session Secret -```bash -node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -``` +## Production Deployment -### 5. Start Application +### Systemd Service -#### Development Mode (with auto-reload) -```bash -npm run dev -``` - -#### Production Mode -```bash -node server.js -``` - -#### As a System Service -Create `/etc/systemd/system/create-a-container.service`: -```ini -[Unit] -Description=Create-a-Container Service -After=network.target mariadb.service - -[Service] -Type=simple -User=www-data -WorkingDirectory=/opt/opensource-server/create-a-container -Environment=NODE_ENV=production -ExecStart=/usr/bin/node server.js -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target -``` - -Enable and start: ```bash +sudo cp systemd/create-a-container.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable create-a-container -sudo systemctl start create-a-container -sudo systemctl status create-a-container -``` - -## API Routes - -### Authentication Routes - -#### `GET /login` -Display login page - -#### `POST /login` -Authenticate user with Proxmox VE credentials -- **Body**: `{ username, password }` -- **Returns**: `{ success: true, redirect: "/" }` - -#### `POST /logout` -End user session - -### Container Management Routes - -#### `GET /` -Redirect to `/containers` - -#### `GET /containers` (Auth Required) -List all containers for authenticated user -- **Returns**: HTML page with container list - -#### `GET /containers/new` (Auth Required) -Display container creation form - -#### `POST /containers` -Create a container asynchronously via a background job -- **Body**: `{ hostname, template, customTemplate, services }` where: - - `hostname`: Container hostname - - `template`: Template selection in format "nodeName,vmid" OR "custom" for Docker images - - `customTemplate`: Docker image reference when template="custom" (e.g., `nginx`, `nginx:alpine`, `myorg/myapp:v1`, `ghcr.io/org/image:tag`) - - `services`: Object of service definitions -- **Returns**: Redirect to containers list with flash message -- **Process**: Creates pending container, services, and job in a single transaction. Docker image references are normalized to full format (`host/org/image:tag`). The job-runner executes the actual Proxmox operations. - -#### `DELETE /containers/:id` (Auth Required) -Delete a container from both Proxmox and the database -- **Path Parameter**: `id` - Container database ID -- **Authorization**: User can only delete their own containers -- **Process**: - 1. Verifies container ownership - 2. Deletes container from Proxmox via API - 3. On success, removes container record from database (cascades to services) -- **Returns**: `{ success: true, message: "Container deleted successfully" }` -- **Errors**: - - `404` - Container not found - - `403` - User doesn't own the container - - `500` - Proxmox API deletion failed or node not configured - -#### `GET /status/:jobId` (Auth Required) -View container creation progress page - -#### `GET /api/stream/:jobId` -SSE stream for real-time container creation progress -- **Returns**: Server-Sent Events stream - -### Job Runner & Jobs API Routes - -#### `POST /jobs` (Admin Auth Required) -Enqueue a job for background execution -- **Body**: `{ "command": "" }` -- **Response**: `201 { id, status }` -- **Authorization**: Admin only (prevents arbitrary command execution) -- **Behavior**: Admin's username is recorded in `createdBy` column for audit trail - -#### `GET /jobs/:id` (Auth Required) -Fetch job metadata (command, status, timestamps) -- **Response**: `{ id, command, status, createdAt, updatedAt, createdBy }` -- **Authorization**: Only the job owner or admins may view -- **Returns**: `404` if unauthorized (prevents information leakage) - -#### `GET /jobs/:id/status` (Auth Required) -Fetch job output rows with offset/limit pagination -- **Query Params**: - - `offset` (optional, default 0) - Skip first N rows - - `limit` (optional, max 1000) - Return up to N rows -- **Response**: Array of JobStatus objects `[{ id, jobId, output, createdAt, updatedAt }, ...]` -- **Authorization**: Only the job owner or admins may view -- **Returns**: `404` if unauthorized - -### Job Runner System - -#### Background Job Execution -The job runner (`job-runner.js`) is a background Node.js process that: -1. Polls the `Jobs` table for `pending` status records -2. Claims a job transactionally (sets status to `running` and acquires row lock) -3. Spawns the job command in a shell subprocess -4. Streams stdout/stderr into the `JobStatuses` table in real-time -5. Updates job status to `success` or `failure` on process exit -6. Gracefully cancels running jobs on shutdown (SIGTERM/SIGINT) and marks them `cancelled` - -#### Data Models - -**Job Model** (`models/job.js`) -``` -id INT PRIMARY KEY AUTO_INCREMENT -command VARCHAR(2000) NOT NULL - shell command to execute -createdBy VARCHAR(255) - username of admin who enqueued (nullable for legacy jobs) -status ENUM('pending', 'running', 'success', 'failure', 'cancelled') -createdAt DATETIME -updatedAt DATETIME -``` - -**JobStatus Model** (`models/jobstatus.js`) -``` -id INT PRIMARY KEY AUTO_INCREMENT -jobId INT NOT NULL (FK → Jobs.id, CASCADE delete) -output TEXT - chunk of stdout/stderr from the job -createdAt DATETIME -updatedAt DATETIME +sudo systemctl enable --now create-a-container ``` -**Migrations** -- `migrations/20251117120000-create-jobs.js` -- `migrations/20251117120001-create-jobstatuses.js` (includes `updatedAt`) -- `migrations/20251117120002-add-job-createdby.js` (adds nullable `createdBy` column + index) +### Job Runner (Background Tasks) -#### Running the Job Runner +The job runner processes container creation tasks asynchronously: -**Development (foreground, logs to stdout)** ```bash -cd create-a-container +# Development npm run job-runner -``` -**Production (systemd service)** -Copy `systemd/job-runner.service` to `/etc/systemd/system/job-runner.service`: -```bash +# Production sudo cp systemd/job-runner.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now job-runner.service -sudo systemctl status job-runner.service -``` - -#### Configuration - -**Database** (via `.env`) -- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`, `DATABASE_DIALECT` - -**Runner Behavior** (environment variables) -- `JOB_RUNNER_POLL_MS` (default 2000) - Polling interval in milliseconds -- `JOB_RUNNER_CWD` (default cwd) - Working directory for spawned commands -- `NODE_ENV=production` - Recommended for production - -**Systemd Setup** (recommended for production) -Create `/etc/default/container-creator` with DB credentials: -```bash -POSTGRES_HOST=localhost -POSTGRES_USER=cluster_manager -POSTGRES_PASSWORD=secure_password_here -POSTGRES_DATABASE=cluster_manager -DATABASE_DIALECT=postgres -``` - -Update `job-runner.service` to include: -```ini -EnvironmentFile=/etc/default/container-creator +sudo systemctl enable --now job-runner ``` -#### Security Considerations - -1. **Command Injection Risk**: The runner spawns commands via shell. Only admins can enqueue jobs via the API. Do not expose `POST /jobs` to untrusted users. -2. **Job Ownership**: Jobs are scoped by `createdBy`. Only the admin who created the job (or other admins) can view its metadata and output. Non-owners receive `404` (not `403`) to prevent information leakage. -3. **Legacy Jobs**: Jobs created before the `createdBy` migration will have `createdBy = NULL` and are visible only to admins. -4. **Graceful Shutdown**: On SIGTERM/SIGINT, the runner kills all running child processes and marks their jobs as `cancelled`. - -#### Testing & Troubleshooting - -**Insert a test job (SQL)** -```sql -INSERT INTO Jobs (command, status, createdAt, updatedAt) -VALUES ('echo "Hello" && sleep 5 && echo "World"', 'pending', NOW(), NOW()); -``` - -**Inspect job status** -```sql -SELECT id, status, updatedAt FROM Jobs ORDER BY id DESC LIMIT 10; -``` - -**View job output** -```sql -SELECT id, output, createdAt FROM JobStatuses WHERE jobId = 1 ORDER BY id ASC; -``` - -**Long-running test (5 minutes)** -1. Stop runner to keep job pending -```bash -sudo systemctl stop job-runner.service -``` -2. Insert job -```bash -psql -c "INSERT INTO \"Jobs\" (command, status, \"createdAt\", \"updatedAt\") VALUES ('for i in \$(seq 1 300); do echo \"line \$i\"; sleep 1; done', 'pending', NOW(), NOW()) RETURNING id;" -``` -3. Start runner and monitor -```bash -node job-runner.js -# In another terminal: -while sleep 15; do - psql -c "SELECT id, output FROM \"JobStatuses\" WHERE \"jobId\"= ORDER BY id ASC;" -done -``` -4. Check final status -```sql -SELECT id, status FROM Jobs WHERE id = ; -``` - -#### Deployment Checklist - -- [ ] Run migrations: `npm run db:migrate` -- [ ] Deploy `job-runner.js` to target host (e.g., `/opt/container-creator/`) -- [ ] Copy `systemd/job-runner.service` to `/etc/systemd/system/` -- [ ] Create `/etc/default/container-creator` with DB env vars -- [ ] Reload systemd: `sudo systemctl daemon-reload` -- [ ] Enable and start: `sudo systemctl enable --now job-runner.service` -- [ ] Verify runner is running: `sudo systemctl status job-runner.service` -- [ ] Test API by creating a job via `POST /jobs` (admin user) - -#### Future Enhancements - -- Replace raw `command` API with safe task names and parameter mapping -- Add SSE or WebSocket streaming endpoint (`/jobs/:id/stream`) to push log lines to frontend in real-time -- Add batching or file-based logs for high-volume output to reduce DB pressure -- Implement job timeout/deadline and automatic cancellation - -### Configuration Routes - -#### `GET /sites/:siteId/nginx` -Generate nginx configuration for all registered services -- **Returns**: `text/plain` - Complete nginx configuration with all server blocks - -### User Registration Routes - -#### `GET /register` -Display account request form - -#### `POST /register` -Submit account request (sends email to admins) -- **Body**: `{ name, email, username, reason }` -- **Returns**: Success message - -### Utility Routes - -#### `GET /send-test-email` (Dev Only) -Test email configuration (development/testing) - -## Database Schema - -### Containers Table -```sql -id INT PRIMARY KEY AUTO_INCREMENT -hostname VARCHAR(255) UNIQUE NOT NULL -username VARCHAR(255) NOT NULL -status VARCHAR(20) NOT NULL DEFAULT 'pending' -template VARCHAR(255) -creationJobId INT FOREIGN KEY REFERENCES Jobs(id) -nodeId INT FOREIGN KEY REFERENCES Nodes(id) -containerId INT UNSIGNED NOT NULL -macAddress VARCHAR(17) UNIQUE -ipv4Address VARCHAR(45) UNIQUE -aiContainer VARCHAR(50) DEFAULT 'N' -createdAt DATETIME -updatedAt DATETIME -``` - -### Services Table -```sql -id INT PRIMARY KEY AUTO_INCREMENT -containerId INT FOREIGN KEY REFERENCES Containers(id) -type ENUM('tcp', 'udp', 'http') NOT NULL -internalPort INT NOT NULL -externalPort INT -tls BOOLEAN DEFAULT FALSE -externalHostname VARCHAR(255) -createdAt DATETIME -updatedAt DATETIME -``` - -## Configuration Files - -### `config/config.js` -Sequelize database configuration (reads from `.env`) - -### `models/` -- `container.js` - Container model definition -- `service.js` - Service model definition -- `index.js` - Sequelize initialization - -### `data/services.json` -Service type definitions and port mappings - -### `views/` -- `login.html` - Login form -- `form.html` - Container creation form -- `request-account.html` - Account request form -- `status.html` - Container creation progress viewer -- `containers.ejs` - Container list (EJS template) -- `nginx-conf.ejs` - Nginx config generator (EJS template) - -### `public/` -- `style.css` - Application styles - -### `migrations/` -Database migration files for schema management - -## Environment Variables - -### Required -- `POSTGRES_HOST` - Database host (default: localhost) -- `POSTGRES_USER` - Database username -- `POSTGRES_PASSWORD` - Database password -- `POSTGRES_DATABASE` - Database name -- `SESSION_SECRET` - Express session secret (cryptographically random string) - -### Optional -- `NODE_ENV` - Environment (development/production, default: development) - -## Security - -### Authentication -- Proxmox VE integration via API -- Session-based authentication with secure cookies -- Per-route authentication middleware - -### Rate Limiting -- 100 requests per 15-minute window per IP -- Protects against brute force and abuse - -### Session Security -- Session secret required for cookie signing -- Secure cookie flag enabled -- Session data server-side only - -### Input Validation -- URL encoding for all parameters -- Sequelize ORM prevents SQL injection -- Form data validation - -## Troubleshooting - -### Database Connection Issues -```bash -# Test database connection -psql -h localhost -U cluster_manager -d cluster_manager - -# Check if migrations ran -npm run db:migrate - -# Verify tables exist -psql -h localhost -U cluster_manager -d cluster_manager -c "\dt" -``` - -### Application Won't Start -```bash -# Check Node.js version -node --version # Should be 18.x or higher - -# Verify .env file exists and is readable -cat .env - -# Check for syntax errors -node -c server.js - -# Run with verbose logging -NODE_ENV=development node server.js -``` - -### Authentication Failing -```bash -# Verify Proxmox API is accessible -curl -k https://10.15.0.4:8006/api2/json/version +## Data Model -# Check if certificate validation is working -# Edit server.js if using self-signed certs +```mermaid +erDiagram + Site ||--o{ Node : contains + Node ||--o{ Container : hosts + Container ||--o{ Service : exposes + + Site { int id PK; string name; string internalDomain } + Node { int id PK; string name UK; string apiUrl; int siteId FK } + Container { int id PK; string hostname UK; string status; int nodeId FK } + Service { int id PK; string type; int internalPort; int containerId FK } ``` -### Email Not Sending -```bash -# Test SMTP connection -telnet mail.example.com 25 +## API Reference -# Test route (development only) -curl http://localhost:3000/send-test-email -``` +See [openapi.yaml](openapi.yaml) for the complete API specification. -### Port Already in Use -```bash -# Find process using port 3000 -sudo lsof -i :3000 +### Key Endpoints -# Change port in .env or kill conflicting process -kill -9 -``` +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/login` | Login page | +| `POST` | `/login` | Authenticate user | +| `GET` | `/sites` | List all sites | +| `GET` | `/sites/:id/nodes` | List nodes in a site | +| `GET` | `/sites/:id/containers` | List containers in a site | +| `POST` | `/containers` | Create container (async job) | +| `DELETE` | `/containers/:id` | Delete container | +| `GET` | `/sites/:siteId/nginx` | Generate nginx config | ## Development ### Database Migrations + ```bash -# Create new migration +# Create migration npx sequelize-cli migration:generate --name description-here # Run migrations -npm run db:migrate +npx sequelize-cli db:migrate # Undo last migration npx sequelize-cli db:migrate:undo ``` -### Code Structure +### Project Structure + ``` create-a-container/ -├── server.js # Main Express application -├── package.json # Dependencies and scripts -├── .env # Environment configuration (gitignored) -├── config/ # Sequelize configuration -├── models/ # Database models -├── migrations/ # Database migrations -├── views/ # HTML templates -├── public/ # Static assets -├── data/ # JSON data files -└── bin/ # Utility scripts +├── server.js # Express application +├── config/ # Database configuration +├── models/ # Sequelize models +├── migrations/ # Database migrations +├── seeders/ # Initial data +├── routers/ # Route handlers +├── views/ # EJS templates +├── public/ # Static assets +└── systemd/ # Service files ``` - -## Integration with Nginx Reverse Proxy - -This application generates nginx configurations consumed by the `nginx-reverse-proxy` component: - -1. Containers register their services in the database -2. The `/sites/:siteId/nginx` endpoint generates complete nginx configs -3. The reverse proxy polls this endpoint via cron -4. Nginx automatically reloads with updated configurations - -See `../nginx-reverse-proxy/README.md` for reverse proxy setup. - -## License - -See the main repository LICENSE file. - -## Support - -For issues, questions, or contributions, see the main opensource-server repository. diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index a5c1b3a8..ce5b6fcb 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -5,6 +5,17 @@ "packages": { "": { "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/formbody": "^8.0.2", + "@fastify/rate-limit": "^10.3.0", + "@fastify/sensible": "^6.0.4", + "@fastify/session": "^11.1.1", + "@fastify/static": "^9.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", + "@fastify/view": "^11.1.1", + "@modelcontextprotocol/sdk": "^1.27.1", "argon2": "^0.44.0", "axios": "^1.13.5", "connect-flash": "^0.1.1", @@ -14,6 +25,8 @@ "express-rate-limit": "^8.1.1", "express-session": "^1.18.2", "express-session-sequelize": "^2.3.0", + "fastify": "^5.8.2", + "fastify-plugin": "^5.1.0", "method-override": "^3.0.0", "morgan": "^1.10.1", "nodemailer": "^7.0.11", @@ -21,11 +34,14 @@ "qrcode": "^1.5.4", "sequelize": "^6.37.8", "sequelize-cli": "^6.6.3", + "sqlite3": "^6.0.1", "swagger-ui-express": "^5.0.1", "yamljs": "^0.3.0" }, "devDependencies": { - "nodemon": "^3.1.10" + "@playwright/test": "^1.58.2", + "nodemon": "^3.1.10", + "pino-pretty": "^13.1.3" } }, "node_modules/@epic-web/invariant": { @@ -33,6 +49,485 @@ "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==" }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/sensible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz", + "integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", + "forwarded": "^0.2.0", + "http-errors": "^2.0.0", + "type-is": "^2.0.1", + "vary": "^1.1.2" + } + }, + "node_modules/@fastify/session": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@fastify/session/-/session-11.1.1.tgz", + "integrity": "sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.1", + "safe-stable-stringify": "^2.4.3" + } + }, + "node_modules/@fastify/static": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz", + "integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/static/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/static/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/static/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz", + "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.5.tgz", + "integrity": "sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^9.0.0", + "fastify-plugin": "^5.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, + "node_modules/@fastify/view": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@fastify/view/-/view-11.1.1.tgz", + "integrity": "sha512-GiHqT3R2eKJgWmy0s45eELTC447a4+lTM2o+8fSWeKwBe9VToeePuHJcKtOEXPrKGSddGO0RsNayULiS3aeHeQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -50,6 +545,107 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "license": "ISC", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "license": "ISC", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -64,6 +660,12 @@ "node": ">=10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -74,6 +676,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -120,6 +738,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -132,6 +756,49 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -185,14 +852,6 @@ "node": ">=16.17.0" } }, - "node_modules/argon2/node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -223,6 +882,35 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/axios": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", @@ -240,6 +928,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -271,6 +979,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -290,25 +1018,10 @@ "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "type-is": "^2.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" }, "funding": { "type": "opencollective", @@ -338,6 +1051,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -346,6 +1083,102 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "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", @@ -407,6 +1240,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -509,6 +1351,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "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", @@ -555,14 +1404,16 @@ } }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dependencies": { - "safe-buffer": "5.2.1" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -589,6 +1440,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -619,6 +1487,16 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -644,6 +1522,30 @@ "node": ">=0.10.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -661,6 +1563,24 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -779,6 +1699,25 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -843,6 +1782,43 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -886,9 +1862,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.1.tgz", - "integrity": "sha512-rvFqXBfcsC4rBEKqGfSKXY6CQZTTJ9yoAnIIXGeW3SYZ4QIfg6MLEXoj6gcax7DGYfEnCSZ4vc9xiwMy8myRWw==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -945,6 +1921,145 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "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/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", + "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -1004,6 +2119,20 @@ "node": ">= 0.8" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1106,19 +2235,38 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/fs.realpath": { @@ -1194,6 +2342,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1314,6 +2468,29 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1333,6 +2510,70 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1501,6 +2742,25 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -1531,6 +2791,54 @@ "node": ">=14" } }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -1543,6 +2851,56 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1561,6 +2919,39 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", + "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1600,73 +2991,234 @@ "vary": "~1.1.2" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.10" + } + }, + "node_modules/method-override/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/method-override/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/method-override/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "license": "MIT", + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/method-override/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", + "yallist": "^4.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, "dependencies": { - "mime-db": "^1.54.0" + "yallist": "^4.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC", + "optional": true + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "license": "ISC", + "optional": true, "dependencies": { - "brace-expansion": "^1.1.7" + "minipass": "^7.1.2" }, "engines": { - "node": "*" + "node": ">=8" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 18" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -1736,6 +3288,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1744,6 +3302,52 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -1754,6 +3358,58 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nodemailer": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", @@ -1816,6 +3472,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1827,6 +3492,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1854,6 +3528,12 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -1881,6 +3561,19 @@ "node": ">=8" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -2076,6 +3769,137 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2124,6 +3948,59 @@ "node": ">=0.10.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -2155,6 +4032,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -2309,6 +4196,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -2339,19 +4232,33 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 6" } }, "node_modules/readdirp": { @@ -2367,6 +4274,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2376,6 +4292,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -2402,12 +4327,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry-as-promised": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", "license": "MIT" }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2442,11 +4392,58 @@ } ] }, + "node_modules/safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2593,6 +4590,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2699,6 +4702,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2712,6 +4760,56 @@ "node": ">=10" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2727,6 +4825,46 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/sqlite3": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", + "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.0.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=20.17.0" + }, + "optionalDependencies": { + "node-gyp": "12.x" + }, + "peerDependencies": { + "node-gyp": "12.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2735,6 +4873,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2831,6 +4978,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2880,6 +5036,116 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tar": { + "version": "7.5.12", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", + "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2893,6 +5159,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2917,6 +5192,18 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -2983,6 +5270,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -3152,6 +5445,30 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yamljs": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", @@ -3254,6 +5571,24 @@ "engines": { "node": ">=8" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/create-a-container/package.json b/create-a-container/package.json index b8fdf19d..6dd5dd33 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -5,11 +5,27 @@ ] }, "scripts": { - "dev": "nodemon server.js", + "start": "node server-fastify.js", + "start:express": "node server.js", + "dev": "nodemon server-fastify.js", + "dev:express": "nodemon server.js", "db:migrate": "sequelize db:migrate && sequelize db:seed:all", - "job-runner": "node job-runner.js" + "job-runner": "node job-runner.js", + "test": "npx playwright test", + "test:ui": "npx playwright test --ui" }, "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/formbody": "^8.0.2", + "@fastify/rate-limit": "^10.3.0", + "@fastify/sensible": "^6.0.4", + "@fastify/session": "^11.1.1", + "@fastify/static": "^9.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", + "@fastify/view": "^11.1.1", + "@modelcontextprotocol/sdk": "^1.27.1", "argon2": "^0.44.0", "axios": "^1.13.5", "connect-flash": "^0.1.1", @@ -19,6 +35,8 @@ "express-rate-limit": "^8.1.1", "express-session": "^1.18.2", "express-session-sequelize": "^2.3.0", + "fastify": "^5.8.2", + "fastify-plugin": "^5.1.0", "method-override": "^3.0.0", "morgan": "^1.10.1", "nodemailer": "^7.0.11", @@ -26,10 +44,13 @@ "qrcode": "^1.5.4", "sequelize": "^6.37.8", "sequelize-cli": "^6.6.3", + "sqlite3": "^6.0.1", "swagger-ui-express": "^5.0.1", "yamljs": "^0.3.0" }, "devDependencies": { - "nodemon": "^3.1.10" + "@playwright/test": "^1.58.2", + "nodemon": "^3.1.10", + "pino-pretty": "^13.1.3" } } diff --git a/create-a-container/playwright.config.js b/create-a-container/playwright.config.js new file mode 100644 index 00000000..45674c19 --- /dev/null +++ b/create-a-container/playwright.config.js @@ -0,0 +1,28 @@ +// @ts-check +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], + webServer: { + command: 'node server.js', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/create-a-container/plugins/auth.js b/create-a-container/plugins/auth.js new file mode 100644 index 00000000..2677674b --- /dev/null +++ b/create-a-container/plugins/auth.js @@ -0,0 +1,143 @@ +const fp = require('fastify-plugin'); + +/** + * Authentication plugin for Fastify + * Provides session and API key authentication with admin checks + */ +async function authPlugin(fastify, options) { + // Helper to check if request wants JSON response + function isApiRequest(request) { + const acceptHeader = request.headers.accept || ''; + const acceptsJSON = acceptHeader.includes('application/json') || acceptHeader.includes('application/vnd.api+json'); + const isAjax = request.headers['x-requested-with'] === 'XMLHttpRequest'; + const isApiPath = request.url?.startsWith('/api/'); + return acceptsJSON || isAjax || isApiPath; + } + + // Decorate request with helper + fastify.decorateRequest('isApiRequest', function() { + return isApiRequest(this); + }); + + // Decorate request with user-related properties + fastify.decorateRequest('user', null); + fastify.decorateRequest('apiKey', null); + fastify.decorateRequest('isAdmin', false); + + /** + * Authentication hook - validates session or API key + */ + async function requireAuth(request, reply) { + // First check session authentication + if (request.session?.user) { + request.isAdmin = request.session.isAdmin || false; + return; + } + + // Try API key authentication + const authHeader = request.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const apiKey = authHeader.substring(7); + + if (apiKey) { + const { ApiKey, User } = require('../models'); + const { extractKeyPrefix } = require('../utils/apikey'); + + const keyPrefix = extractKeyPrefix(apiKey); + + const apiKeys = await ApiKey.findAll({ + where: { keyPrefix }, + include: [{ + model: User, + as: 'user', + include: [{ association: 'groups' }] + }] + }); + + for (const storedKey of apiKeys) { + const isValid = await storedKey.validateKey(apiKey); + if (isValid) { + request.user = storedKey.user; + request.apiKey = storedKey; + request.isAdmin = storedKey.user.groups?.some(g => g.isAdmin) || false; + + // Populate session for compatibility + request.session.user = storedKey.user.uid; + request.session.isAdmin = request.isAdmin; + + // Record usage asynchronously + storedKey.recordUsage().catch(err => { + fastify.log.error('Failed to update API key last used timestamp:', err); + }); + + return; + } + } + } + } + + // Neither session nor API key authentication succeeded + if (isApiRequest(request)) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + + // Browser request - redirect to login + const original = request.url || '/'; + const redirectTo = '/login?redirect=' + encodeURIComponent(original); + return reply.redirect(redirectTo); + } + + /** + * Admin check hook - must be used after requireAuth + */ + async function requireAdmin(request, reply) { + if (request.session?.isAdmin || request.isAdmin) { + return; + } + + if (isApiRequest(request)) { + return reply.code(403).send({ error: 'Forbidden: Admin access required' }); + } + + return reply.code(403).send('Forbidden: Admin access required'); + } + + /** + * Localhost or admin check hook + * Allows localhost requests without auth; remote requests need admin + */ + async function requireLocalhostOrAdmin(request, reply) { + const isLocalhost = (ip) => { + return ip === '127.0.0.1' || + ip === '::1' || + ip === '::ffff:127.0.0.1' || + ip === 'localhost'; + }; + + const directIp = request.ip; + const realIp = request.headers['x-real-ip']; + + // If direct connection is from localhost and no non-localhost X-Real-IP, allow through + if (isLocalhost(directIp) && (!realIp || isLocalhost(realIp))) { + return; + } + + // Not localhost — require auth + admin + await requireAuth(request, reply); + if (reply.sent) return; + await requireAdmin(request, reply); + } + + // Decorate fastify with auth hooks so routes can use them + fastify.decorate('requireAuth', requireAuth); + fastify.decorate('requireAdmin', requireAdmin); + fastify.decorate('requireLocalhostOrAdmin', requireLocalhostOrAdmin); + + // Also expose isApiRequest as a utility + fastify.decorate('isApiRequest', isApiRequest); +} + +module.exports = fp(authPlugin, { + name: 'auth', + dependencies: ['@fastify/session'] +}); diff --git a/create-a-container/plugins/flash.js b/create-a-container/plugins/flash.js new file mode 100644 index 00000000..30623985 --- /dev/null +++ b/create-a-container/plugins/flash.js @@ -0,0 +1,65 @@ +const fp = require('fastify-plugin'); + +/** + * Flash messages plugin for Fastify + * Provides Express-compatible flash() functionality using sessions + */ +async function flashPlugin(fastify, options) { + // Decorate request with flash method + fastify.decorateRequest('flash', function(type, message) { + if (!this.session) { + throw new Error('Flash requires session support'); + } + + // Initialize flash storage + if (!this.session.flash) { + this.session.flash = {}; + } + + // If no arguments, return and clear all flash messages + if (arguments.length === 0) { + const messages = this.session.flash || {}; + this.session.flash = {}; + return messages; + } + + // If only type provided, return messages for that type + if (arguments.length === 1) { + const messages = this.session.flash[type] || []; + delete this.session.flash[type]; + return messages; + } + + // Add message to flash + if (!this.session.flash[type]) { + this.session.flash[type] = []; + } + this.session.flash[type].push(message); + + return this.session.flash[type].length; + }); + + // Pre-handler to expose flash messages to views + fastify.addHook('preHandler', async (request, reply) => { + // Make flash messages available to templates + reply.locals = reply.locals || {}; + + // Get flash messages without clearing them yet + const flashMessages = request.session?.flash || {}; + + reply.locals.successMessages = flashMessages.success || []; + reply.locals.errorMessages = flashMessages.error || []; + reply.locals.warningMessages = flashMessages.warning || []; + reply.locals.infoMessages = flashMessages.info || []; + + // Clear flash after reading + if (request.session) { + request.session.flash = {}; + } + }); +} + +module.exports = fp(flashPlugin, { + name: 'flash', + dependencies: ['@fastify/session'] +}); diff --git a/create-a-container/plugins/load-sites.js b/create-a-container/plugins/load-sites.js new file mode 100644 index 00000000..31060816 --- /dev/null +++ b/create-a-container/plugins/load-sites.js @@ -0,0 +1,49 @@ +const fp = require('fastify-plugin'); + +/** + * Load sites plugin for Fastify + * Loads all sites for authenticated users and attaches to reply.locals + */ +async function loadSitesPlugin(fastify, options) { + const { Site } = require('../models'); + + fastify.addHook('preHandler', async (request, reply) => { + // Only load sites for authenticated users + if (!request.session?.user) { + return; + } + + reply.locals = reply.locals || {}; + + try { + const sites = await Site.findAll({ + attributes: ['id', 'name'], + order: [['name', 'ASC']] + }); + reply.locals.sites = sites; + reply.locals.currentSite = request.session.currentSite || null; + } catch (error) { + fastify.log.error('Error loading sites:', error); + reply.locals.sites = []; + reply.locals.currentSite = null; + } + }); + + /** + * Helper to set current site from route param + */ + function setCurrentSite(request, reply) { + if (request.params.siteId) { + request.session.currentSite = parseInt(request.params.siteId, 10); + reply.locals = reply.locals || {}; + reply.locals.currentSite = request.session.currentSite; + } + } + + fastify.decorate('setCurrentSite', setCurrentSite); +} + +module.exports = fp(loadSitesPlugin, { + name: 'load-sites', + dependencies: ['@fastify/session'] +}); diff --git a/create-a-container/plugins/mcp.js b/create-a-container/plugins/mcp.js new file mode 100644 index 00000000..56cc2aee --- /dev/null +++ b/create-a-container/plugins/mcp.js @@ -0,0 +1,333 @@ +const fp = require('fastify-plugin'); +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); +const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); +const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js'); + +/** + * MCP Server Plugin for Fastify + * Exposes container management tools for AI agents + */ +async function mcpPlugin(fastify, options) { + const { Container, Job, JobStatus, Site, Node, ApiKey, User } = require('../models'); + const { createApiKeyData } = require('../utils/apikey'); + + // Create MCP server instance + const mcpServer = new McpServer({ + name: 'create-a-container', + version: '2.0.0' + }); + + // --- Tool Definitions --- + + // List containers tool + mcpServer.tool( + 'list_containers', + 'List all containers for a user. Returns container IDs, hostnames, status, and SSH/HTTP access info.', + { + type: 'object', + properties: { + siteId: { type: 'number', description: 'Optional site ID to filter containers' }, + hostname: { type: 'string', description: 'Optional hostname filter' } + } + }, + async ({ siteId, hostname }, { meta }) => { + try { + const where = {}; + if (hostname) where.hostname = hostname; + + let containers; + if (siteId) { + const nodes = await Node.findAll({ where: { siteId }, attributes: ['id'] }); + where.nodeId = nodes.map(n => n.id); + } + + containers = await Container.findAll({ + where, + include: [ + { association: 'services', include: [{ association: 'httpService', include: [{ association: 'externalDomain' }] }, { association: 'transportService' }] }, + { association: 'node', attributes: ['id', 'name'] }, + { association: 'site', attributes: ['id', 'name', 'externalIp'] } + ], + limit: 100 + }); + + const results = containers.map(c => { + const services = c.services || []; + const ssh = services.find(s => s.type === 'transport' && s.transportService?.protocol === 'tcp' && Number(s.internalPort) === 22); + const http = services.find(s => s.type === 'http'); + + return { + id: c.id, + hostname: c.hostname, + status: c.status, + ipv4Address: c.ipv4Address, + template: c.template, + nodeName: c.node?.name, + siteName: c.site?.name, + sshPort: ssh?.transportService?.externalPort, + sshHost: c.site?.externalIp, + httpUrl: http?.httpService?.externalHostname && http?.httpService?.externalDomain?.name + ? `https://${http.httpService.externalHostname}.${http.httpService.externalDomain.name}` + : null, + createdAt: c.createdAt + }; + }); + + return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; + } + } + ); + + // Get container details tool + mcpServer.tool( + 'get_container', + 'Get detailed information about a specific container by ID or hostname.', + { + type: 'object', + properties: { + id: { type: 'number', description: 'Container ID' }, + hostname: { type: 'string', description: 'Container hostname' } + } + }, + async ({ id, hostname }) => { + try { + const where = id ? { id } : { hostname }; + const container = await Container.findOne({ + where, + include: [ + { association: 'services', include: [{ association: 'httpService', include: [{ association: 'externalDomain' }] }, { association: 'transportService' }, { association: 'dnsService' }] }, + { association: 'node', attributes: { exclude: ['secret'] } }, + { association: 'site' }, + { association: 'creationJob' } + ] + }); + + if (!container) { + return { content: [{ type: 'text', text: 'Container not found' }], isError: true }; + } + + return { content: [{ type: 'text', text: JSON.stringify(container, null, 2) }] }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; + } + } + ); + + // Get job status tool + mcpServer.tool( + 'get_job_status', + 'Get the status and output of a container creation/reconfiguration job.', + { + type: 'object', + properties: { + jobId: { type: 'number', description: 'Job ID' } + }, + required: ['jobId'] + }, + async ({ jobId }) => { + try { + const job = await Job.findByPk(jobId); + if (!job) { + return { content: [{ type: 'text', text: 'Job not found' }], isError: true }; + } + + const statuses = await JobStatus.findAll({ + where: { jobId }, + order: [['id', 'ASC']], + limit: 100 + }); + + const output = statuses.map(s => s.output).join('\n'); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + id: job.id, + status: job.status, + command: job.command, + createdBy: job.createdBy, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + output: output + }, null, 2) + }] + }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; + } + } + ); + + // List sites tool + mcpServer.tool( + 'list_sites', + 'List all available sites.', + { type: 'object', properties: {} }, + async () => { + try { + const sites = await Site.findAll({ + include: [{ model: Node, as: 'nodes', attributes: ['id', 'name'] }], + order: [['name', 'ASC']] + }); + + const results = sites.map(s => ({ + id: s.id, + name: s.name, + internalDomain: s.internalDomain, + gateway: s.gateway, + externalIp: s.externalIp, + nodeCount: s.nodes?.length || 0, + nodes: s.nodes?.map(n => ({ id: n.id, name: n.name })) + })); + + return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; + } + } + ); + + // List nodes tool + mcpServer.tool( + 'list_nodes', + 'List all nodes, optionally filtered by site.', + { + type: 'object', + properties: { + siteId: { type: 'number', description: 'Optional site ID filter' } + } + }, + async ({ siteId }) => { + try { + const where = siteId ? { siteId } : {}; + const nodes = await Node.findAll({ + where, + attributes: { exclude: ['secret'] }, + include: [ + { model: Container, as: 'containers', attributes: ['id'] }, + { model: Site, as: 'site', attributes: ['id', 'name'] } + ], + order: [['name', 'ASC']] + }); + + const results = nodes.map(n => ({ + id: n.id, + name: n.name, + ipv4Address: n.ipv4Address, + apiUrl: n.apiUrl, + siteName: n.site?.name, + containerCount: n.containers?.length || 0 + })); + + return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; + } + } + ); + + // --- Resource Definitions --- + + // Container resource + mcpServer.resource( + 'container', + 'container://{id}', + async (uri) => { + const id = parseInt(uri.pathname.replace(/^\//, ''), 10); + const container = await Container.findByPk(id, { + include: [ + { association: 'services', include: [{ association: 'httpService' }, { association: 'transportService' }] }, + { association: 'node', attributes: { exclude: ['secret'] } }, + { association: 'site' } + ] + }); + + if (!container) { + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Container not found' }] }; + } + + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(container, null, 2) + }] + }; + } + ); + + // Job resource + mcpServer.resource( + 'job', + 'job://{id}', + async (uri) => { + const id = parseInt(uri.pathname.replace(/^\//, ''), 10); + const job = await Job.findByPk(id); + const statuses = await JobStatus.findAll({ + where: { jobId: id }, + order: [['id', 'ASC']], + limit: 500 + }); + + if (!job) { + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Job not found' }] }; + } + + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify({ + ...job.toJSON(), + output: statuses.map(s => s.output).join('\n') + }, null, 2) + }] + }; + } + ); + + // --- HTTP SSE Transport for MCP --- + fastify.get('/mcp/sse', { + schema: { + tags: ['MCP'], + summary: 'MCP Server-Sent Events endpoint', + description: 'Connect to the MCP server via SSE for AI agent communication' + } + }, async (request, reply) => { + // Set SSE headers + reply.raw.setHeader('Content-Type', 'text/event-stream'); + reply.raw.setHeader('Cache-Control', 'no-cache'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + + const transport = new SSEServerTransport('/mcp/messages', reply.raw); + await mcpServer.connect(transport); + + request.raw.on('close', () => { + transport.close(); + }); + }); + + // MCP message endpoint for SSE transport + fastify.post('/mcp/messages', { + schema: { + tags: ['MCP'], + summary: 'MCP message endpoint', + description: 'Send messages to the MCP server' + } + }, async (request, reply) => { + // This is handled by the SSE transport + return reply.code(200).send({ ok: true }); + }); + + // Decorate fastify with MCP server for CLI usage + fastify.decorate('mcpServer', mcpServer); +} + +module.exports = fp(mcpPlugin, { + name: 'mcp', + dependencies: ['@fastify/session'] +}); diff --git a/create-a-container/routers/login.js b/create-a-container/routers/login.js index e67f683b..47b439ab 100644 --- a/create-a-container/routers/login.js +++ b/create-a-container/routers/login.js @@ -1,18 +1,98 @@ const express = require('express'); const router = express.Router(); +const fs = require('fs'); +const path = require('path'); const { User, Setting } = require('../models'); const { isSafeRelativeUrl } = require('../utils'); +// Check if we're in dev mode (no .env file or NODE_ENV !== 'production') +function isDevMode() { + const envPath = path.join(__dirname, '..', '.env'); + const hasEnvFile = fs.existsSync(envPath); + return !hasEnvFile || process.env.NODE_ENV !== 'production'; +} + // GET / - Display login form -router.get('/', (req, res) => { +router.get('/', async (req, res) => { + const userCount = await User.count(); + const devMode = isDevMode(); res.render('login', { successMessages: req.flash('success'), errorMessages: req.flash('error'), warningMessages: req.flash('warning'), - redirect: req.query.redirect || '/' + redirect: req.query.redirect || '/', + noUsers: userCount === 0, + showQuickLogin: devMode }); }); +// POST /quick - Create test user and auto-login (dev mode only) +router.post('/quick', async (req, res) => { + // Only allow in dev mode + if (!isDevMode()) { + await req.flash('error', 'Quick login is only available in development mode'); + return res.redirect('/login'); + } + + const role = req.body.role || 'admin'; + const isAdmin = role === 'admin'; + const username = isAdmin ? 'admin' : 'testuser'; + const displayName = isAdmin ? 'Admin User' : 'Test User'; + + try { + // Find existing user or create new one + let user = await User.findOne({ + where: { uid: username }, + include: [{ association: 'groups' }] + }); + + if (!user) { + user = await User.create({ + uidNumber: await User.nextUidNumber(), + uid: username, + givenName: isAdmin ? 'Admin' : 'Test', + sn: 'User', + cn: displayName, + mail: `${username}@localhost`, + userPassword: 'test', + status: 'active', + homeDirectory: `/home/${username}`, + }); + + // For admin users, ensure they're in sysadmins group + if (isAdmin) { + const { Group } = require('../models'); + const sysadminsGroup = await Group.findByPk(2000); + if (sysadminsGroup) { + await user.addGroup(sysadminsGroup); + } + } + // Reload user with groups + user = await User.findOne({ + where: { uid: username }, + include: [{ association: 'groups' }] + }); + } + + // Auto-login: set session variables based on user's actual groups + const userIsAdmin = user.groups?.some(g => g.isAdmin) || false; + req.session.user = user.uid; + req.session.isAdmin = userIsAdmin; + + // Save session and redirect to app + req.session.save((err) => { + if (err) { + console.error('Session save error:', err); + } + return res.redirect('/'); + }); + } catch (err) { + console.error('Quick login error:', err); + await req.flash('error', 'Failed to create user: ' + err.message); + return res.redirect('/login'); + } +}); + // POST / - Handle login submission router.post('/', async (req, res) => { const { username, password } = req.body; diff --git a/create-a-container/routers/register.js b/create-a-container/routers/register.js index dc000f7b..756dc214 100644 --- a/create-a-container/routers/register.js +++ b/create-a-container/routers/register.js @@ -74,8 +74,10 @@ router.post('/', async (req, res) => { // Determine user status let status; + let isFirstUser = false; if (await User.count() === 0) { status = 'active'; // First user is always active + isFirstUser = true; } else if (isInvitedUser) { status = 'active'; // Invited users are auto-activated } else { @@ -132,6 +134,8 @@ router.post('/', async (req, res) => { await req.flash('warning', `Account created, but 2FA invite failed: ${inviteResult.error}`); } await req.flash('success', 'Account created successfully! You can now log in.'); + } else if (isFirstUser) { + await req.flash('success', 'Admin account created successfully! You can now log in.'); } else { await req.flash('success', 'Account registered successfully. You will be notified via email once approved.'); } diff --git a/create-a-container/routes/apikeys.js b/create-a-container/routes/apikeys.js new file mode 100644 index 00000000..d7fe285c --- /dev/null +++ b/create-a-container/routes/apikeys.js @@ -0,0 +1,263 @@ +const { ApiKey, User } = require('../models'); +const { createApiKeyData } = require('../utils/apikey'); + +// JSON Schemas for auto-documentation +const apiKeySchema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + keyPrefix: { type: 'string' }, + description: { type: 'string' }, + lastUsedAt: { type: 'string', format: 'date-time', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' } + } +}; + +const createApiKeySchema = { + body: { + type: 'object', + properties: { + description: { type: 'string', description: 'Human-readable label for this key' } + } + } +}; + +async function apikeysRoutes(fastify, options) { + // Apply auth to all routes + fastify.addHook('preHandler', fastify.requireAuth); + + // GET / - List all API keys for current user + fastify.get('/', { + schema: { + tags: ['API Keys'], + summary: 'List API keys', + description: 'Returns all API keys belonging to the authenticated user. The full key value is never returned — only the prefix.', + security: [{ BearerAuth: [] }], + response: { + 200: { + description: 'List of API keys', + type: 'object', + properties: { + apiKeys: { type: 'array', items: apiKeySchema } + } + } + } + } + }, async (request, reply) => { + const user = await User.findOne({ where: { uid: request.session.user } }); + if (!user) { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/login'); + } + + const apiKeys = await ApiKey.findAll({ + where: { uidNumber: user.uidNumber }, + order: [['createdAt', 'DESC']], + attributes: ['id', 'keyPrefix', 'description', 'lastUsedAt', 'createdAt', 'updatedAt'] + }); + + if (request.isApiRequest()) { + return { apiKeys }; + } + + return reply.view('apikeys/index', { apiKeys, req: request }); + }); + + // GET /new - Display form for creating a new API key + fastify.get('/new', { + schema: { + tags: ['API Keys'], + summary: 'New API key form', + description: 'Display form for creating a new API key (HTML only)' + } + }, async (request, reply) => { + return reply.view('apikeys/form', { req: request }); + }); + + // POST / - Create a new API key + fastify.post('/', { + schema: { + tags: ['API Keys'], + summary: 'Create an API key', + description: 'Creates a new API key. The full key is returned **only once** in the response. Store it securely — it cannot be retrieved again.', + security: [{ BearerAuth: [] }], + body: createApiKeySchema.body, + response: { + 201: { + description: 'API key created', + type: 'object', + properties: { + apiKey: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + key: { type: 'string', description: 'Full API key (shown only once)' }, + keyPrefix: { type: 'string' }, + description: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' } + } + }, + warning: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const user = await User.findOne({ where: { uid: request.session.user } }); + if (!user) { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/login'); + } + + const { description } = request.body || {}; + const apiKeyData = await createApiKeyData(user.uidNumber, description); + + const apiKey = await ApiKey.create({ + uidNumber: apiKeyData.uidNumber, + keyPrefix: apiKeyData.keyPrefix, + keyHash: apiKeyData.keyHash, + description: apiKeyData.description + }); + + if (request.isApiRequest()) { + return reply.code(201).send({ + apiKey: { + id: apiKey.id, + key: apiKeyData.plainKey, + keyPrefix: apiKey.keyPrefix, + description: apiKey.description, + createdAt: apiKey.createdAt + }, + warning: 'This is the only time the full API key will be displayed. Please store it securely.' + }); + } + + request.flash('success', 'API key created successfully. This is the only time it will be shown!'); + return reply.view('apikeys/created', { + plainKey: apiKeyData.plainKey, + apiKey, + req: request + }); + }); + + // GET /:id - Show details of a specific API key + fastify.get('/:id', { + schema: { + tags: ['API Keys'], + summary: 'Get API key details', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' } + }, + required: ['id'] + }, + response: { + 200: { + description: 'API key details', + type: 'object', + properties: { + apiKey: apiKeySchema + } + }, + 404: { + type: 'object', + properties: { error: { type: 'string' } } + } + } + } + }, async (request, reply) => { + const user = await User.findOne({ where: { uid: request.session.user } }); + if (!user) { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/login'); + } + + const { id } = request.params; + const apiKey = await ApiKey.findOne({ + where: { id, uidNumber: user.uidNumber }, + attributes: ['id', 'keyPrefix', 'description', 'lastUsedAt', 'createdAt', 'updatedAt'] + }); + + if (!apiKey) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'API key not found' }); + } + request.flash('error', 'API key not found'); + return reply.redirect('/apikeys'); + } + + if (request.isApiRequest()) { + return { apiKey }; + } + + return reply.view('apikeys/show', { apiKey, req: request }); + }); + + // DELETE /:id - Delete an API key + fastify.delete('/:id', { + schema: { + tags: ['API Keys'], + summary: 'Delete an API key', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' } + }, + required: ['id'] + }, + response: { + 204: { description: 'API key deleted' }, + 404: { + type: 'object', + properties: { error: { type: 'string' } } + } + } + } + }, async (request, reply) => { + const user = await User.findOne({ where: { uid: request.session.user } }); + if (!user) { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/login'); + } + + const { id } = request.params; + const apiKey = await ApiKey.findOne({ + where: { id, uidNumber: user.uidNumber } + }); + + if (!apiKey) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'API key not found' }); + } + request.flash('error', 'API key not found'); + return reply.redirect('/apikeys'); + } + + await apiKey.destroy(); + + if (request.isApiRequest()) { + return reply.code(204).send(); + } + + request.flash('success', 'API key deleted successfully'); + return reply.redirect('/apikeys'); + }); +} + +module.exports = apikeysRoutes; diff --git a/create-a-container/routes/containers.js b/create-a-container/routes/containers.js new file mode 100644 index 00000000..27677999 --- /dev/null +++ b/create-a-container/routes/containers.js @@ -0,0 +1,720 @@ +const { Container, Service, HTTPService, TransportService, DnsService, Node, Site, ExternalDomain, Job, Sequelize, sequelize } = require('../models'); +const { parseDockerRef, getImageConfig, extractImageMetadata } = require('../utils/docker-registry'); +const { manageDnsRecords } = require('../utils/cloudflare-dns'); +const { isValidHostname } = require('../utils'); + +/** + * Normalize a Docker image reference to full format: host/org/image:tag + */ +function normalizeDockerRef(ref) { + if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('git@')) { + return ref; + } + + let tag = 'latest'; + let imagePart = ref; + + const lastColon = ref.lastIndexOf(':'); + if (lastColon !== -1) { + const potentialTag = ref.substring(lastColon + 1); + if (!potentialTag.includes('/')) { + tag = potentialTag; + imagePart = ref.substring(0, lastColon); + } + } + + const parts = imagePart.split('/'); + let host = 'docker.io'; + let org = 'library'; + let image; + + if (parts.length === 1) { + image = parts[0]; + } else if (parts.length === 2) { + if (parts[0].includes('.') || parts[0].includes(':')) { + host = parts[0]; + image = parts[1]; + } else { + org = parts[0]; + image = parts[1]; + } + } else { + host = parts[0]; + image = parts[parts.length - 1]; + org = parts.slice(1, -1).join('/'); + } + + return `${host}/${org}/${image}:${tag}`; +} + +async function containersRoutes(fastify, options) { + // Helper to get siteId from parent route + function getSiteId(request) { + return parseInt(request.params.siteId, 10); + } + + // GET /metadata - Fetch Docker image metadata + fastify.get('/metadata', { + preHandler: [fastify.requireAuth], + schema: { + tags: ['Containers'], + summary: 'Fetch Docker image metadata', + security: [{ BearerAuth: [] }], + querystring: { + type: 'object', + properties: { + image: { type: 'string' } + }, + required: ['image'] + } + } + }, async (request, reply) => { + try { + const { image } = request.query; + + if (!image || !image.trim()) { + return reply.code(400).send({ error: 'Image parameter is required' }); + } + + const normalizedImage = normalizeDockerRef(image.trim()); + const parsed = parseDockerRef(normalizedImage); + const repo = `${parsed.namespace}/${parsed.image}`; + const config = await getImageConfig(parsed.registry, repo, parsed.tag); + const metadata = extractImageMetadata(config); + + return metadata; + } catch (err) { + fastify.log.error('Error fetching image metadata:', err); + + let errorMessage = 'Failed to fetch image metadata'; + if (err.message.includes('HTTP 404')) { + errorMessage = 'Image not found in registry'; + } else if (err.message.includes('timeout')) { + errorMessage = 'Request timed out. Registry may be unavailable.'; + } else if (err.message.includes('auth')) { + errorMessage = 'Authentication failed. Image may be private.'; + } + + return reply.code(500).send({ error: errorMessage, details: err.message }); + } + }); + + // GET /new - List available templates via API or HTML form + fastify.get('/new', { + preHandler: [fastify.requireAuth], + schema: { + tags: ['Containers'], + summary: 'Container creation form / template list', + security: [{ BearerAuth: [] }] + } + }, async (request, reply) => { + const siteId = getSiteId(request); + + const site = await Site.findByPk(siteId); + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const externalDomains = await ExternalDomain.findAll({ order: [['name', 'ASC']] }); + + if (request.isApiRequest()) { + return { site_id: site.id, domains: externalDomains }; + } + + return reply.view('containers/form', { + site, + externalDomains, + container: undefined, + req: request + }); + }); + + // GET / - List containers + fastify.get('/', { + preHandler: [fastify.requireAuth], + schema: { + tags: ['Containers'], + summary: 'List containers', + security: [{ BearerAuth: [] }], + querystring: { + type: 'object', + properties: { + hostname: { type: 'string' } + } + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const site = await Site.findByPk(siteId); + + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const nodes = await Node.findAll({ where: { siteId }, attributes: ['id'] }); + const nodeIds = nodes.map(n => n.id); + + const { hostname } = request.query; + const where = { username: request.session.user, nodeId: nodeIds }; + if (hostname) where.hostname = hostname; + + const containers = await Container.findAll({ + where, + include: [ + { + association: 'services', + include: [ + { association: 'httpService', include: [{ association: 'externalDomain' }] }, + { association: 'transportService' } + ] + }, + { association: 'node', attributes: ['id', 'name', 'apiUrl'] } + ] + }); + + const rows = containers.map(c => { + const services = c.services || []; + const ssh = services.find(s => s.type === 'transport' && s.transportService?.protocol === 'tcp' && Number(s.internalPort) === 22); + const sshPort = ssh?.transportService?.externalPort || null; + const http = services.find(s => s.type === 'http'); + const httpPort = http ? http.internalPort : null; + const httpExternalHost = http?.httpService?.externalHostname && http?.httpService?.externalDomain?.name + ? `${http.httpService.externalHostname}.${http.httpService.externalDomain.name}` + : null; + const httpExternalUrl = httpExternalHost ? `https://${httpExternalHost}` : null; + + return { + id: c.id, + containerId: c.containerId, + hostname: c.hostname, + ipv4Address: c.ipv4Address, + macAddress: c.macAddress, + status: c.status, + template: c.template, + creationJobId: c.creationJobId, + sshPort, + sshHost: httpExternalHost || site.externalIp, + httpPort, + httpExternalUrl, + nodeName: c.node ? c.node.name : '-', + nodeApiUrl: c.node ? c.node.apiUrl : null, + createdAt: c.createdAt + }; + }); + + if (request.isApiRequest()) { + return { containers: rows }; + } + + return reply.view('containers/index', { rows, site, req: request }); + }); + + // GET /:id/edit - Edit container form + fastify.get('/:id/edit', { + preHandler: [fastify.requireAuth] + }, async (request, reply) => { + const siteId = getSiteId(request); + const containerId = parseInt(request.params.id, 10); + + const site = await Site.findByPk(siteId); + if (!site) { + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const container = await Container.findOne({ + where: { id: containerId, username: request.session.user }, + include: [ + { model: Node, as: 'node', where: { siteId } }, + { + model: Service, + as: 'services', + include: [ + { model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] }, + { model: TransportService, as: 'transportService' }, + { model: DnsService, as: 'dnsService' } + ] + } + ] + }); + + if (!container) { + request.flash('error', 'Container not found'); + return reply.redirect(`/sites/${siteId}/containers`); + } + + const externalDomains = await ExternalDomain.findAll({ order: [['name', 'ASC']] }); + + return reply.view('containers/form', { + site, + container, + externalDomains, + templates: [], + isEdit: true, + req: request + }); + }); + + // POST / - Create a container + fastify.post('/', { + preHandler: [fastify.requireAuth], + schema: { + tags: ['Containers'], + summary: 'Create a container', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + hostname: { type: 'string' }, + template: { type: 'string' }, + template_name: { type: 'string' }, + customTemplate: { type: 'string' }, + repository: { type: 'string' }, + branch: { type: 'string' }, + entrypoint: { type: 'string' }, + environmentVars: { type: 'array', items: { type: 'object' } }, + services: { type: 'object' } + }, + required: ['hostname'] + }, + response: { + 202: { + type: 'object', + properties: { + message: { type: 'string' }, + status: { type: 'string' }, + jobId: { type: 'integer' }, + container: { type: 'object' } + } + } + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + + const site = await Site.findByPk(siteId); + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const t = await sequelize.transaction(); + + try { + let { hostname, template, customTemplate, services, environmentVars, entrypoint, template_name, repository, branch } = request.body; + + // API Payload Mapping + if (request.isApiRequest()) { + if (template_name && !template) { + template = template_name; + } + + if (repository) { + if (!environmentVars) environmentVars = []; + if (!Array.isArray(environmentVars)) environmentVars = []; + environmentVars.push({ key: 'BUILD_REPOSITORY', value: repository }); + environmentVars.push({ key: 'BUILD_BRANCH', value: branch || 'master' }); + } + } + + if (hostname) hostname = hostname.trim().toLowerCase(); + if (!isValidHostname(hostname)) { + throw new Error('Invalid hostname: must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit'); + } + + const currentUser = request.session?.user || request.user?.username || 'api-user'; + + let envVarsJson = null; + if (environmentVars && Array.isArray(environmentVars)) { + const envObj = {}; + for (const env of environmentVars) { + if (env.key && env.key.trim()) { + envObj[env.key.trim()] = env.value || ''; + } + } + if (Object.keys(envObj).length > 0) { + envVarsJson = JSON.stringify(envObj); + } + } + + const imageRef = (template === 'custom') ? customTemplate?.trim() : template; + if (!imageRef) { + throw new Error('A container template is required'); + } + const templateName = normalizeDockerRef(imageRef); + + const node = await Node.findOne({ + where: { + siteId, + apiUrl: { [Sequelize.Op.ne]: null }, + tokenId: { [Sequelize.Op.ne]: null }, + secret: { [Sequelize.Op.ne]: null } + } + }); + if (!node) { + throw new Error('No nodes with API access available in this site'); + } + + const container = await Container.create({ + hostname, + username: currentUser, + status: 'pending', + template: templateName, + nodeId: node.id, + siteId, + containerId: null, + macAddress: null, + ipv4Address: null, + environmentVars: envVarsJson, + entrypoint: entrypoint && entrypoint.trim() ? entrypoint.trim() : null + }, { transaction: t }); + + // Services creation + if (services && typeof services === 'object') { + for (const key in services) { + const service = services[key]; + const { type, internalPort, externalHostname, externalDomainId, dnsName } = service; + + if (!type || !internalPort) continue; + + let serviceType; + let protocol = null; + + if (type === 'http' || type === 'https') { + serviceType = 'http'; + } else if (type === 'srv') { + serviceType = 'dns'; + } else { + serviceType = 'transport'; + protocol = type; + } + + const serviceData = { + containerId: container.id, + type: serviceType, + internalPort: parseInt(internalPort, 10) + }; + + const createdService = await Service.create(serviceData, { transaction: t }); + + if (serviceType === 'http') { + if (!externalHostname || !externalDomainId) { + throw new Error('HTTP services must have both an external hostname and external domain'); + } + await HTTPService.create({ + serviceId: createdService.id, + externalHostname, + externalDomainId: parseInt(externalDomainId, 10), + backendProtocol: type === 'https' ? 'https' : 'http' + }, { transaction: t }); + } else if (serviceType === 'dns') { + if (!dnsName) throw new Error('DNS services must have a DNS name'); + await DnsService.create({ + serviceId: createdService.id, + recordType: 'SRV', + dnsName + }, { transaction: t }); + } else { + const minPort = 2000; + const maxPort = 65565; + const externalPort = await TransportService.nextAvailablePortInRange(protocol, minPort, maxPort, t); + await TransportService.create({ + serviceId: createdService.id, + protocol: protocol, + externalPort + }, { transaction: t }); + } + } + } + + const job = await Job.create({ + command: `node bin/create-container.js --container-id=${container.id}`, + createdBy: currentUser, + status: 'pending' + }, { transaction: t }); + + await container.update({ creationJobId: job.id }, { transaction: t }); + await t.commit(); + + if (request.isApiRequest()) { + return reply.code(202).send({ + message: 'Container creation initiated', + status: 'pending', + jobId: job.id, + container: { + id: container.id, + hostname: container.hostname, + status: 'pending' + } + }); + } + + request.flash('success', `Container "${hostname}" is being created.`); + return reply.redirect(`/jobs/${job.id}`); + } catch (err) { + await t.rollback(); + fastify.log.error('Error creating container:', err); + + let errorMessage = 'Failed to create container: '; + if (err.response?.data?.message) { + errorMessage += err.response.data.message; + } else { + errorMessage += err.message; + } + + if (request.isApiRequest()) { + return reply.code(400).send({ error: errorMessage, details: err.message }); + } + + request.flash('error', errorMessage); + return reply.redirect(`/sites/${siteId}/containers/new`); + } + }); + + // PUT /:id - Update a container + fastify.put('/:id', { + preHandler: [fastify.requireAuth], + schema: { + tags: ['Containers'], + summary: 'Update a container', + security: [{ BearerAuth: [] }] + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const containerId = parseInt(request.params.id, 10); + + if (request.isApiRequest()) { + try { + const container = await Container.findByPk(containerId); + if (!container) return reply.code(404).send({ error: 'Not found' }); + await container.update({ + ipv4Address: request.body.ipv4Address ?? container.ipv4Address, + macAddress: request.body.macAddress ?? container.macAddress, + osRelease: request.body.osRelease ?? container.osRelease + }); + return { message: 'Updated' }; + } catch (err) { + fastify.log.error('API PUT Error:', err); + return reply.code(500).send({ error: 'Internal server error' }); + } + } + + const site = await Site.findByPk(siteId); + if (!site) return reply.redirect('/sites'); + + try { + const container = await Container.findOne({ + where: { id: containerId, username: request.session.user }, + include: [{ model: Node, as: 'node', where: { siteId } }] + }); + + if (!container) { + request.flash('error', 'Container not found'); + return reply.redirect(`/sites/${siteId}/containers`); + } + + const { services, environmentVars, entrypoint } = request.body; + const forceRestart = request.body.restart === 'true'; + const isRestartOnly = forceRestart && !services && !environmentVars && entrypoint === undefined; + + let envVarsJson = container.environmentVars; + if (!isRestartOnly && environmentVars && Array.isArray(environmentVars)) { + const envObj = {}; + for (const env of environmentVars) { + if (env.key) envObj[env.key.trim()] = env.value || ''; + } + envVarsJson = Object.keys(envObj).length > 0 ? JSON.stringify(envObj) : null; + } else if (!isRestartOnly && !environmentVars) { + envVarsJson = null; + } + + const newEntrypoint = isRestartOnly ? container.entrypoint : + (entrypoint && entrypoint.trim() ? entrypoint.trim() : null); + + const envChanged = !isRestartOnly && container.environmentVars !== envVarsJson; + const entrypointChanged = !isRestartOnly && container.entrypoint !== newEntrypoint; + const needsRestart = forceRestart || envChanged || entrypointChanged; + + let restartJob = null; + let dnsWarnings = []; + await sequelize.transaction(async (t) => { + if (envChanged || entrypointChanged) { + await container.update({ + environmentVars: envVarsJson, + entrypoint: newEntrypoint, + status: needsRestart && container.containerId ? 'restarting' : container.status + }, { transaction: t }); + } else if (forceRestart && container.containerId) { + await container.update({ status: 'restarting' }, { transaction: t }); + } + + if (needsRestart && container.containerId) { + restartJob = await Job.create({ + command: `node bin/reconfigure-container.js --container-id=${container.id}`, + createdBy: request.session.user, + status: 'pending' + }, { transaction: t }); + } + + if (services && typeof services === 'object') { + const deletedHttpServices = []; + for (const key in services) { + const { id, deleted } = services[key]; + if (deleted === 'true' && id) { + const svc = await Service.findByPk(parseInt(id, 10), { + include: [{ model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] }], + transaction: t + }); + if (svc?.httpService?.externalDomain) { + deletedHttpServices.push({ externalHostname: svc.httpService.externalHostname, ExternalDomain: svc.httpService.externalDomain }); + } + await Service.destroy({ + where: { id: parseInt(id, 10), containerId: container.id }, + transaction: t + }); + } + } + + const newHttpServices = []; + for (const key in services) { + const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName } = services[key]; + if (deleted === 'true' || id || !type || !internalPort) continue; + + let serviceType = type === 'srv' ? 'dns' : ((type === 'http' || type === 'https') ? 'http' : 'transport'); + const protocol = (serviceType === 'transport') ? type : null; + + const createdService = await Service.create({ + containerId: container.id, + type: serviceType, + internalPort: parseInt(internalPort, 10) + }, { transaction: t }); + + if (serviceType === 'http') { + await HTTPService.create({ serviceId: createdService.id, externalHostname, externalDomainId, backendProtocol: type === 'https' ? 'https' : 'http' }, { transaction: t }); + const domain = await ExternalDomain.findByPk(parseInt(externalDomainId, 10), { transaction: t }); + if (domain) newHttpServices.push({ externalHostname, ExternalDomain: domain }); + } else if (serviceType === 'dns') { + await DnsService.create({ serviceId: createdService.id, recordType: 'SRV', dnsName }, { transaction: t }); + } else { + const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565); + await TransportService.create({ serviceId: createdService.id, protocol, externalPort }, { transaction: t }); + } + } + + dnsWarnings = []; + if (deletedHttpServices.length > 0) { + dnsWarnings.push(...await manageDnsRecords(deletedHttpServices, site, 'delete')); + } + if (newHttpServices.length > 0) { + dnsWarnings.push(...await manageDnsRecords(newHttpServices, site, 'create')); + } + } + }); + + if (restartJob) { + let msg = 'Container configuration updated. Restarting container...'; + for (const w of dnsWarnings) msg += ` ⚠️ ${w}`; + request.flash('success', msg); + return reply.redirect(`/jobs/${restartJob.id}`); + } else { + let msg = 'Container services updated successfully'; + for (const w of dnsWarnings) msg += ` ⚠️ ${w}`; + request.flash('success', msg); + } + return reply.redirect(`/sites/${siteId}/containers`); + } catch (err) { + fastify.log.error('Error updating container:', err); + request.flash('error', 'Failed to update container: ' + err.message); + return reply.redirect(`/sites/${siteId}/containers/${containerId}/edit`); + } + }); + + // DELETE /:id - Delete a container + fastify.delete('/:id', { + preHandler: [fastify.requireAuth], + schema: { + tags: ['Containers'], + summary: 'Delete a container', + security: [{ BearerAuth: [] }] + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const containerId = parseInt(request.params.id, 10); + + if (request.isApiRequest()) { + try { + const container = await Container.findByPk(containerId); + if (!container) return reply.code(404).send({ error: 'Not found' }); + await container.destroy(); + return reply.code(204).send(); + } catch (err) { + fastify.log.error('API DELETE Error:', err); + return reply.code(500).send({ error: 'Internal server error' }); + } + } + + const site = await Site.findByPk(siteId); + if (!site) return reply.redirect('/sites'); + + const container = await Container.findOne({ + where: { id: containerId, username: request.session.user }, + include: [ + { model: Node, as: 'node' }, + { model: Service, as: 'services', include: [{ model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] }] } + ] + }); + + if (!container || !container.node || container.node.siteId !== siteId) { + request.flash('error', 'Container not found or access denied'); + return reply.redirect(`/sites/${siteId}/containers`); + } + + const node = container.node; + let dnsWarnings = []; + try { + const httpServices = (container.services || []) + .filter(s => s.httpService?.externalDomain) + .map(s => ({ externalHostname: s.httpService.externalHostname, ExternalDomain: s.httpService.externalDomain })); + if (httpServices.length > 0) { + dnsWarnings = await manageDnsRecords(httpServices, site, 'delete'); + } + + if (container.containerId && node.apiUrl && node.tokenId) { + const api = await node.api(); + try { + const config = await api.lxcConfig(node.name, container.containerId); + if (config.hostname && config.hostname !== container.hostname) { + request.flash('error', `Hostname mismatch (DB: ${container.hostname} vs Proxmox: ${config.hostname}). Delete aborted.`); + return reply.redirect(`/sites/${siteId}/containers`); + } + await api.deleteContainer(node.name, container.containerId, true, true); + } catch (proxmoxError) { + fastify.log.info(`Proxmox deletion skipped or failed: ${proxmoxError.message}`); + } + } + await container.destroy(); + } catch (error) { + fastify.log.error(error); + request.flash('error', `Failed to delete: ${error.message}`); + return reply.redirect(`/sites/${siteId}/containers`); + } + + let msg = 'Container deleted successfully'; + for (const w of dnsWarnings) msg += ` ⚠️ ${w}`; + request.flash('success', msg); + return reply.redirect(`/sites/${siteId}/containers`); + }); +} + +module.exports = containersRoutes; diff --git a/create-a-container/routes/external-domains.js b/create-a-container/routes/external-domains.js new file mode 100644 index 00000000..61b2dc4e --- /dev/null +++ b/create-a-container/routes/external-domains.js @@ -0,0 +1,257 @@ +const { ExternalDomain, Site } = require('../models'); + +async function externalDomainsRoutes(fastify, options) { + // All routes require authentication + admin + fastify.addHook('preHandler', fastify.requireAuth); + fastify.addHook('preHandler', fastify.requireAdmin); + + // GET / - List all external domains + fastify.get('/', { + schema: { + tags: ['External Domains'], + summary: 'List external domains', + security: [{ BearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + domains: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + acmeEmail: { type: 'string' }, + acmeDirectoryUrl: { type: 'string' }, + cloudflareApiEmail: { type: 'string' }, + defaultSite: { type: 'string', nullable: true } + } + } + } + } + } + } + } + }, async (request, reply) => { + const externalDomains = await ExternalDomain.findAll({ + include: [{ model: Site, as: 'site', attributes: ['id', 'name'], required: false }], + order: [['name', 'ASC']] + }); + + const rows = externalDomains.map(d => ({ + id: d.id, + name: d.name, + acmeEmail: d.acmeEmail, + acmeDirectoryUrl: d.acmeDirectoryUrl, + cloudflareApiEmail: d.cloudflareApiEmail, + defaultSite: d.site ? d.site.name : null + })); + + if (request.isApiRequest()) { + return { domains: rows }; + } + + return reply.view('external-domains/index', { rows, req: request }); + }); + + // GET /new - Display form for creating a new external domain + fastify.get('/new', async (request, reply) => { + const sites = await Site.findAll({ order: [['name', 'ASC']] }); + return reply.view('external-domains/form', { + externalDomain: null, + sites, + isEdit: false, + req: request + }); + }); + + // GET /:id/edit - Display form for editing an external domain + fastify.get('/:id/edit', { + schema: { + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const domainId = parseInt(request.params.id, 10); + const externalDomain = await ExternalDomain.findByPk(domainId); + + if (!externalDomain) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'External domain not found' }); + } + request.flash('error', 'External domain not found'); + return reply.redirect('/external-domains'); + } + + const sites = await Site.findAll({ order: [['name', 'ASC']] }); + return reply.view('external-domains/form', { + externalDomain, + sites, + isEdit: true, + req: request + }); + }); + + // POST / - Create a new external domain + fastify.post('/', { + schema: { + tags: ['External Domains'], + summary: 'Create external domain', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + acmeEmail: { type: 'string' }, + acmeDirectoryUrl: { type: 'string' }, + cloudflareApiEmail: { type: 'string' }, + cloudflareApiKey: { type: 'string' }, + siteId: { type: 'integer' } + }, + required: ['name'] + } + } + }, async (request, reply) => { + try { + const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId } = request.body; + + const domain = await ExternalDomain.create({ + name, + acmeEmail: acmeEmail || null, + acmeDirectoryUrl: acmeDirectoryUrl || null, + cloudflareApiEmail: cloudflareApiEmail || null, + cloudflareApiKey: cloudflareApiKey || null, + siteId: siteId || null + }); + + if (request.isApiRequest()) { + return reply.code(201).send({ success: true, domain: { id: domain.id, name: domain.name } }); + } + + request.flash('success', `External domain ${name} created successfully`); + return reply.redirect('/external-domains'); + } catch (error) { + fastify.log.error('Error creating external domain:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to create external domain: ' + error.message }); + } + request.flash('error', 'Failed to create external domain: ' + error.message); + return reply.redirect('/external-domains/new'); + } + }); + + // PUT /:id - Update an external domain + fastify.put('/:id', { + schema: { + tags: ['External Domains'], + summary: 'Update external domain', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const domainId = parseInt(request.params.id, 10); + + try { + const externalDomain = await ExternalDomain.findByPk(domainId); + + if (!externalDomain) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'External domain not found' }); + } + request.flash('error', 'External domain not found'); + return reply.redirect('/external-domains'); + } + + const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId } = request.body; + + const updateData = { + name, + acmeEmail: acmeEmail || null, + acmeDirectoryUrl: acmeDirectoryUrl || null, + cloudflareApiEmail: cloudflareApiEmail || null, + siteId: siteId || null + }; + + if (cloudflareApiKey && cloudflareApiKey.trim() !== '') { + updateData.cloudflareApiKey = cloudflareApiKey; + } + + await externalDomain.update(updateData); + + if (request.isApiRequest()) { + return { success: true, message: `External domain ${name} updated successfully` }; + } + + request.flash('success', `External domain ${name} updated successfully`); + return reply.redirect('/external-domains'); + } catch (error) { + fastify.log.error('Error updating external domain:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to update external domain: ' + error.message }); + } + request.flash('error', 'Failed to update external domain: ' + error.message); + return reply.redirect(`/external-domains/${domainId}/edit`); + } + }); + + // DELETE /:id - Delete an external domain + fastify.delete('/:id', { + schema: { + tags: ['External Domains'], + summary: 'Delete external domain', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const domainId = parseInt(request.params.id, 10); + + try { + const externalDomain = await ExternalDomain.findByPk(domainId); + + if (!externalDomain) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'External domain not found' }); + } + request.flash('error', 'External domain not found'); + return reply.redirect('/external-domains'); + } + + const domainName = externalDomain.name; + await externalDomain.destroy(); + + if (request.isApiRequest()) { + return reply.code(204).send(); + } + + request.flash('success', `External domain ${domainName} deleted successfully`); + return reply.redirect('/external-domains'); + } catch (error) { + fastify.log.error('Error deleting external domain:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to delete external domain: ' + error.message }); + } + request.flash('error', 'Failed to delete external domain: ' + error.message); + return reply.redirect('/external-domains'); + } + }); +} + +module.exports = externalDomainsRoutes; diff --git a/create-a-container/routes/groups.js b/create-a-container/routes/groups.js new file mode 100644 index 00000000..e2f764ed --- /dev/null +++ b/create-a-container/routes/groups.js @@ -0,0 +1,231 @@ +const { Group, User } = require('../models'); + +async function groupsRoutes(fastify, options) { + // Apply auth and admin check to all routes + fastify.addHook('preHandler', fastify.requireAuth); + fastify.addHook('preHandler', fastify.requireAdmin); + + // GET / - List all groups + fastify.get('/', { + schema: { + tags: ['Groups'], + summary: 'List all groups', + security: [{ BearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + groups: { + type: 'array', + items: { + type: 'object', + properties: { + gidNumber: { type: 'integer' }, + cn: { type: 'string' }, + isAdmin: { type: 'boolean' }, + userCount: { type: 'integer' } + } + } + } + } + } + } + } + }, async (request, reply) => { + const groups = await Group.findAll({ + include: [{ + model: User, + as: 'users', + attributes: ['uidNumber', 'uid'], + through: { attributes: [] } + }], + order: [['gidNumber', 'ASC']] + }); + + const rows = groups.map(g => ({ + gidNumber: g.gidNumber, + cn: g.cn, + isAdmin: g.isAdmin, + userCount: g.users ? g.users.length : 0 + })); + + if (request.isApiRequest()) { + return { groups: rows }; + } + + return reply.view('groups/index', { rows, req: request }); + }); + + // GET /new - Display form for creating a new group + fastify.get('/new', async (request, reply) => { + return reply.view('groups/form', { + group: null, + isEdit: false, + req: request + }); + }); + + // GET /:id/edit - Display form for editing an existing group + fastify.get('/:id/edit', { + schema: { + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const group = await Group.findByPk(request.params.id); + + if (!group) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Group not found' }); + } + request.flash('error', 'Group not found'); + return reply.redirect('/groups'); + } + + return reply.view('groups/form', { + group, + isEdit: true, + req: request + }); + }); + + // POST / - Create a new group + fastify.post('/', { + schema: { + tags: ['Groups'], + summary: 'Create a new group', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + gidNumber: { type: 'integer' }, + cn: { type: 'string' }, + isAdmin: { type: 'string' } + }, + required: ['gidNumber', 'cn'] + } + } + }, async (request, reply) => { + try { + const { gidNumber, cn, isAdmin } = request.body; + + const group = await Group.create({ + gidNumber: parseInt(gidNumber), + cn, + isAdmin: isAdmin === 'on' || isAdmin === 'true' + }); + + if (request.isApiRequest()) { + return reply.code(201).send({ success: true, group: { gidNumber: group.gidNumber, cn: group.cn } }); + } + request.flash('success', `Group ${cn} created successfully`); + return reply.redirect('/groups'); + } catch (error) { + fastify.log.error('Error creating group:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to create group: ' + error.message }); + } + request.flash('error', 'Failed to create group: ' + error.message); + return reply.redirect('/groups/new'); + } + }); + + // PUT /:id - Update an existing group + fastify.put('/:id', { + schema: { + tags: ['Groups'], + summary: 'Update a group', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + try { + const group = await Group.findByPk(request.params.id); + + if (!group) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Group not found' }); + } + request.flash('error', 'Group not found'); + return reply.redirect('/groups'); + } + + const { cn, isAdmin } = request.body; + + await group.update({ + cn, + isAdmin: isAdmin === 'on' || isAdmin === 'true' + }); + + if (request.isApiRequest()) { + return { success: true, message: `Group ${cn} updated successfully` }; + } + request.flash('success', `Group ${cn} updated successfully`); + return reply.redirect('/groups'); + } catch (error) { + fastify.log.error('Error updating group:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to update group: ' + error.message }); + } + request.flash('error', 'Failed to update group: ' + error.message); + return reply.redirect(`/groups/${request.params.id}/edit`); + } + }); + + // DELETE /:id - Delete a group + fastify.delete('/:id', { + schema: { + tags: ['Groups'], + summary: 'Delete a group', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + try { + const group = await Group.findByPk(request.params.id); + + if (!group) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Group not found' }); + } + request.flash('error', 'Group not found'); + return reply.redirect('/groups'); + } + + const groupName = group.cn; + await group.destroy(); + + if (request.isApiRequest()) { + return reply.code(204).send(); + } + request.flash('success', `Group ${groupName} deleted successfully`); + return reply.redirect('/groups'); + } catch (error) { + fastify.log.error('Error deleting group:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to delete group: ' + error.message }); + } + request.flash('error', 'Failed to delete group: ' + error.message); + return reply.redirect('/groups'); + } + }); +} + +module.exports = groupsRoutes; diff --git a/create-a-container/routes/jobs.js b/create-a-container/routes/jobs.js new file mode 100644 index 00000000..8dd908cb --- /dev/null +++ b/create-a-container/routes/jobs.js @@ -0,0 +1,256 @@ +const { Job, JobStatus, Container, Node, sequelize, Sequelize } = require('../models'); + +async function canAccessJob(job, request) { + const username = request.session?.user; + const isAdmin = request.session?.isAdmin; + return isAdmin || job.createdBy === username; +} + +async function jobsRoutes(fastify, options) { + // All job endpoints require authentication + fastify.addHook('preHandler', fastify.requireAuth); + + // POST / - enqueue a new job (admins only) + fastify.post('/', { + preHandler: [fastify.requireAdmin], + schema: { + tags: ['Jobs'], + summary: 'Create a new job', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + command: { type: 'string', minLength: 1, maxLength: 2000 } + }, + required: ['command'] + }, + response: { + 201: { + type: 'object', + properties: { + id: { type: 'integer' }, + status: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + try { + const { command } = request.body; + if (!command || typeof command !== 'string' || command.trim().length === 0) { + return reply.code(400).send({ error: 'command is required' }); + } + + if (command.length > 2000) { + return reply.code(400).send({ error: 'command too long' }); + } + + const owner = request.session?.user || null; + const job = await Job.create({ command: command.trim(), createdBy: owner }); + return reply.code(201).send({ id: job.id, status: job.status }); + } catch (err) { + fastify.log.error('Failed to enqueue job:', err); + return reply.code(500).send({ error: 'Failed to create job' }); + } + }); + + // GET /:id - job metadata (HTML or JSON) + fastify.get('/:id', { + schema: { + tags: ['Jobs'], + summary: 'Get job details', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + try { + const id = parseInt(request.params.id, 10); + const job = await Job.findByPk(id); + + if (!job) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Job not found' }); + } + request.flash('error', 'Job not found'); + return reply.redirect('/'); + } + + if (!await canAccessJob(job, request)) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Job not found' }); + } + request.flash('error', 'Job not found'); + return reply.redirect('/'); + } + + if (request.isApiRequest()) { + return { + id: job.id, + command: job.command, + status: job.status, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + createdBy: job.createdBy + }; + } + + const initialOutput = await JobStatus.findAll({ + where: { jobId: id }, + order: [['id', 'ASC']], + limit: 1000 + }); + + const container = await Container.findOne({ + where: { creationJobId: id }, + include: [{ model: Node, as: 'node' }] + }); + + return reply.view('jobs/show', { + job, + initialOutput, + container, + req: request + }); + } catch (err) { + fastify.log.error('Failed to fetch job:', err); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to fetch job' }); + } + request.flash('error', 'Failed to load job'); + return reply.redirect('/'); + } + }); + + // GET /:id/stream - SSE endpoint for streaming job output + fastify.get('/:id/stream', { + schema: { + tags: ['Jobs'], + summary: 'Stream job output (SSE)', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const id = parseInt(request.params.id, 10); + + try { + const job = await Job.findByPk(id); + if (!job || !await canAccessJob(job, request)) { + return reply.code(404).send({ error: 'Job not found' }); + } + + // Set up SSE headers + reply.raw.setHeader('Content-Type', 'text/event-stream'); + reply.raw.setHeader('Cache-Control', 'no-cache'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + reply.raw.flushHeaders(); + + let lastId = request.query.lastId ? parseInt(request.query.lastId, 10) : 0; + let isRunning = true; + + const keepaliveInterval = setInterval(() => { + if (isRunning) { + reply.raw.write(':keepalive\n\n'); + } + }, 15000); + + const pollInterval = setInterval(async () => { + try { + const newLogs = await JobStatus.findAll({ + where: { + jobId: id, + id: { [Sequelize.Op.gt]: lastId } + }, + order: [['id', 'ASC']], + limit: 100 + }); + + for (const log of newLogs) { + reply.raw.write(`event: log\ndata: ${JSON.stringify({ id: log.id, output: log.output, timestamp: log.createdAt })}\n\n`); + lastId = log.id; + } + + const currentJob = await Job.findByPk(id); + if (!currentJob || !['pending', 'running'].includes(currentJob.status)) { + reply.raw.write(`event: status\ndata: ${JSON.stringify({ status: currentJob ? currentJob.status : 'unknown' })}\n\n`); + cleanup(); + reply.raw.end(); + } + } catch (err) { + fastify.log.error('SSE poll error:', err); + } + }, 2000); + + function cleanup() { + isRunning = false; + clearInterval(keepaliveInterval); + clearInterval(pollInterval); + } + + request.raw.on('close', cleanup); + } catch (err) { + fastify.log.error('SSE setup error:', err); + return reply.code(500).send({ error: 'Failed to start stream' }); + } + }); + + // GET /:id/status - fetch job status rows + fastify.get('/:id/status', { + schema: { + tags: ['Jobs'], + summary: 'Get job status logs', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + }, + querystring: { + type: 'object', + properties: { + offset: { type: 'integer', default: 0 }, + limit: { type: 'integer', default: 1000, maximum: 1000 } + } + } + } + }, async (request, reply) => { + try { + const id = parseInt(request.params.id, 10); + const offset = Math.max(0, parseInt(request.query.offset, 10) || 0); + const limit = Math.min(1000, parseInt(request.query.limit, 10) || 1000); + + const job = await Job.findByPk(id); + if (!job || !await canAccessJob(job, request)) { + return reply.code(404).send({ error: 'Job not found' }); + } + + const rows = await JobStatus.findAll({ + where: { jobId: id }, + order: [['createdAt', 'ASC']], + limit, + offset + }); + + return rows; + } catch (err) { + fastify.log.error('Failed to fetch job status:', err); + return reply.code(500).send({ error: 'Failed to fetch job status' }); + } + }); +} + +module.exports = jobsRoutes; diff --git a/create-a-container/routes/login.js b/create-a-container/routes/login.js new file mode 100644 index 00000000..7f22005a --- /dev/null +++ b/create-a-container/routes/login.js @@ -0,0 +1,302 @@ +const fs = require('fs'); +const path = require('path'); +const { User, Setting } = require('../models'); +const { isSafeRelativeUrl } = require('../utils'); + +// Check if we're in dev mode (no .env file or NODE_ENV !== 'production') +function isDevMode() { + const envPath = path.join(__dirname, '..', '.env'); + const hasEnvFile = fs.existsSync(envPath); + return !hasEnvFile || process.env.NODE_ENV !== 'production'; +} + +// JSON Schema for request/response validation +const loginSchema = { + body: { + type: 'object', + properties: { + username: { type: 'string', minLength: 1 }, + password: { type: 'string', minLength: 1 }, + redirect: { type: 'string' } + }, + required: ['username', 'password'] + } +}; + +const quickLoginSchema = { + body: { + type: 'object', + properties: { + role: { type: 'string', enum: ['admin', 'user'] } + } + } +}; + +async function loginRoutes(fastify, options) { + // GET / - Display login form + fastify.get('/', { + schema: { + tags: ['Authentication'], + summary: 'Login page', + description: 'Display login form or return login status for API requests', + response: { + 200: { + description: 'Login form or status', + type: 'object', + properties: { + authenticated: { type: 'boolean' }, + noUsers: { type: 'boolean' }, + showQuickLogin: { type: 'boolean' } + } + } + } + } + }, async (request, reply) => { + const userCount = await User.count(); + const devMode = isDevMode(); + + if (request.isApiRequest()) { + return { + authenticated: !!request.session?.user, + noUsers: userCount === 0, + showQuickLogin: devMode + }; + } + + return reply.view('login', { + successMessages: reply.locals?.successMessages || [], + errorMessages: reply.locals?.errorMessages || [], + warningMessages: reply.locals?.warningMessages || [], + redirect: request.query.redirect || '/', + noUsers: userCount === 0, + showQuickLogin: devMode + }); + }); + + // POST /quick - Create test user and auto-login (dev mode only) + fastify.post('/quick', { + schema: quickLoginSchema + }, async (request, reply) => { + // Only allow in dev mode + if (!isDevMode()) { + if (request.isApiRequest()) { + return reply.code(403).send({ error: 'Quick login is only available in development mode' }); + } + request.flash('error', 'Quick login is only available in development mode'); + return reply.redirect('/login'); + } + + const role = request.body.role || 'admin'; + const isAdmin = role === 'admin'; + const username = isAdmin ? 'admin' : 'testuser'; + const displayName = isAdmin ? 'Admin User' : 'Test User'; + + try { + // Find existing user or create new one + let user = await User.findOne({ + where: { uid: username }, + include: [{ association: 'groups' }] + }); + + if (!user) { + user = await User.create({ + uidNumber: await User.nextUidNumber(), + uid: username, + givenName: isAdmin ? 'Admin' : 'Test', + sn: 'User', + cn: displayName, + mail: `${username}@localhost`, + userPassword: 'test', + status: 'active', + homeDirectory: `/home/${username}`, + }); + + // For admin users, ensure they're in sysadmins group + if (isAdmin) { + const { Group } = require('../models'); + const sysadminsGroup = await Group.findByPk(2000); + if (sysadminsGroup) { + await user.addGroup(sysadminsGroup); + } + } + // Reload user with groups + user = await User.findOne({ + where: { uid: username }, + include: [{ association: 'groups' }] + }); + } + + // Auto-login: set session variables based on user's actual groups + const userIsAdmin = user.groups?.some(g => g.isAdmin) || false; + request.session.user = user.uid; + request.session.isAdmin = userIsAdmin; + + if (request.isApiRequest()) { + return { success: true, user: user.uid, isAdmin: userIsAdmin }; + } + + return reply.redirect('/'); + } catch (err) { + fastify.log.error('Quick login error:', err); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to create user: ' + err.message }); + } + request.flash('error', 'Failed to create user: ' + err.message); + return reply.redirect('/login'); + } + }); + + // POST / - Handle login submission + fastify.post('/', { + schema: { + tags: ['Authentication'], + summary: 'Login', + description: 'Authenticate user with username and password', + body: loginSchema.body, + response: { + 200: { + description: 'Login successful', + type: 'object', + properties: { + success: { type: 'boolean' }, + user: { type: 'string' }, + isAdmin: { type: 'boolean' }, + redirect: { type: 'string' } + } + }, + 401: { + description: 'Invalid credentials', + type: 'object', + properties: { + error: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const { username, password } = request.body; + + const user = await User.findOne({ + where: { uid: username }, + include: [{ association: 'groups' }] + }); + + if (!user) { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'Invalid username or password' }); + } + request.flash('error', 'Invalid username or password'); + return reply.redirect('/login'); + } + + const isValidPassword = await user.validatePassword(password); + if (!isValidPassword) { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'Invalid username or password' }); + } + request.flash('error', 'Invalid username or password'); + return reply.redirect('/login'); + } + + if (user.status !== 'active') { + if (request.isApiRequest()) { + return reply.code(401).send({ error: 'Account is not active. Please contact the administrator.' }); + } + request.flash('error', 'Account is not active. Please contact the administrator.'); + return reply.redirect('/login'); + } + + // Check if push notification 2FA is enabled + const settings = await Setting.getMultiple(['push_notification_url', 'push_notification_enabled']); + const pushNotificationUrl = settings.push_notification_url || ''; + const pushNotificationEnabled = settings.push_notification_enabled === 'true'; + + if (pushNotificationEnabled && pushNotificationUrl.trim() !== '') { + const notificationPayload = { + username: user.uid, + title: 'Authentication Request', + body: 'Please review and respond to your pending authentication request.', + actions: [ + { icon: 'approve', title: 'Approve', callback: 'approve' }, + { icon: 'reject', title: 'Reject', callback: 'reject' } + ] + }; + + try { + const response = await fetch(`${pushNotificationUrl}/send-notification`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(notificationPayload) + }); + + const result = await response.json(); + + // Check for user not registered errors + if (result.success === false && + (result.error?.includes('No device found with this Username') || + result.error?.includes('User not found'))) { + const errorMsg = `No device found with this username. Please register your device at: ${pushNotificationUrl}`; + if (request.isApiRequest()) { + return reply.code(401).send({ error: errorMsg, registrationUrl: pushNotificationUrl }); + } + request.flash('error', `No device found with this username. Please register your device at: ${pushNotificationUrl}`); + return reply.redirect('/login'); + } + + if (!response.ok) { + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to send push notification. Please contact support.' }); + } + request.flash('error', 'Failed to send push notification. Please contact support.'); + return reply.redirect('/login'); + } + + if (result.action !== 'approve') { + let errorMsg; + if (result.action === 'reject') { + errorMsg = 'Second factor push notification was denied.'; + } else if (result.action === 'timeout') { + errorMsg = 'Second factor push notification timed out. Please try again.'; + } else { + errorMsg = `Second factor push notification failed: ${result.action}. Please contact support.`; + } + if (request.isApiRequest()) { + return reply.code(401).send({ error: errorMsg }); + } + request.flash('error', errorMsg); + return reply.redirect('/login'); + } + } catch (error) { + fastify.log.error('Push notification error:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to send push notification. Please contact support.' }); + } + request.flash('error', 'Failed to send push notification. Please contact support.'); + return reply.redirect('/login'); + } + } + + // Set session variables + request.session.user = user.uid; + request.session.isAdmin = user.groups?.some(group => group.isAdmin) || false; + + // Return redirect to original page or default to home + let redirectUrl = request.body.redirect || '/'; + if (!isSafeRelativeUrl(redirectUrl)) { + redirectUrl = '/'; + } + + if (request.isApiRequest()) { + return { + success: true, + user: user.uid, + isAdmin: request.session.isAdmin, + redirect: redirectUrl + }; + } + + return reply.redirect(redirectUrl); + }); +} + +module.exports = loginRoutes; diff --git a/create-a-container/routes/nodes.js b/create-a-container/routes/nodes.js new file mode 100644 index 00000000..178ee95f --- /dev/null +++ b/create-a-container/routes/nodes.js @@ -0,0 +1,503 @@ +const https = require('https'); +const { Node, Container, Site } = require('../models'); + +async function nodesRoutes(fastify, options) { + // Apply auth and admin check to all routes + fastify.addHook('preHandler', fastify.requireAuth); + fastify.addHook('preHandler', fastify.requireAdmin); + + // Helper to get siteId from parent route + function getSiteId(request) { + return parseInt(request.params.siteId, 10); + } + + // GET / - List all nodes for a site + fastify.get('/', { + schema: { + tags: ['Nodes'], + summary: 'List nodes for a site', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + siteId: { type: 'integer' } + }, + required: ['siteId'] + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + + const site = await Site.findByPk(siteId); + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const nodes = await Node.findAll({ + where: { siteId }, + include: [{ model: Container, as: 'containers', attributes: ['id'] }], + attributes: { exclude: ['secret'] } + }); + + const rows = nodes.map(n => ({ + id: n.id, + name: n.name, + ipv4Address: n.ipv4Address, + apiUrl: n.apiUrl, + tlsVerify: n.tlsVerify, + containerCount: n.containers ? n.containers.length : 0 + })); + + if (request.isApiRequest()) { + return { nodes: rows }; + } + + return reply.view('nodes/index', { rows, site, req: request }); + }); + + // GET /new - Display form for creating a new node + fastify.get('/new', async (request, reply) => { + const siteId = getSiteId(request); + const site = await Site.findByPk(siteId); + + if (!site) { + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + return reply.view('nodes/form', { + node: null, + site, + isEdit: false, + req: request + }); + }); + + // GET /import - Display form for importing nodes + fastify.get('/import', async (request, reply) => { + const siteId = getSiteId(request); + const site = await Site.findByPk(siteId); + + if (!site) { + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + return reply.view('nodes/import', { site, req: request }); + }); + + // GET /:id/edit - Display form for editing a node + fastify.get('/:id/edit', { + schema: { + params: { + type: 'object', + properties: { + siteId: { type: 'integer' }, + id: { type: 'integer' } + }, + required: ['siteId', 'id'] + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const nodeId = parseInt(request.params.id, 10); + + const site = await Site.findByPk(siteId); + if (!site) { + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const node = await Node.findOne({ + where: { id: nodeId, siteId }, + attributes: { exclude: ['secret'] } + }); + + if (!node) { + request.flash('error', 'Node not found'); + return reply.redirect(`/sites/${siteId}/nodes`); + } + + return reply.view('nodes/form', { + node, + site, + isEdit: true, + req: request + }); + }); + + // POST / - Create a new node + fastify.post('/', { + schema: { + tags: ['Nodes'], + summary: 'Create a new node', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + siteId: { type: 'integer' } + }, + required: ['siteId'] + }, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + ipv4Address: { type: 'string' }, + apiUrl: { type: 'string' }, + tokenId: { type: 'string' }, + secret: { type: 'string' }, + tlsVerify: { type: 'string' }, + imageStorage: { type: 'string' }, + volumeStorage: { type: 'string' } + }, + required: ['name'] + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + + try { + const site = await Site.findByPk(siteId); + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage } = request.body; + + const node = await Node.create({ + name, + ipv4Address: ipv4Address || null, + apiUrl: apiUrl || null, + tokenId: tokenId || null, + secret: secret || null, + tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true', + imageStorage: imageStorage || 'local', + volumeStorage: volumeStorage || 'local-lvm', + siteId + }); + + if (request.isApiRequest()) { + return reply.code(201).send({ success: true, node: { id: node.id, name: node.name } }); + } + + request.flash('success', `Node ${name} created successfully`); + return reply.redirect(`/sites/${siteId}/nodes`); + } catch (err) { + fastify.log.error('Error creating node:', err); + if (request.isApiRequest()) { + return reply.code(400).send({ error: `Failed to create node: ${err.message}` }); + } + request.flash('error', `Failed to create node: ${err.message}`); + return reply.redirect(`/sites/${siteId}/nodes/new`); + } + }); + + // POST /import - Import nodes from Proxmox API + fastify.post('/import', { + schema: { + tags: ['Nodes'], + summary: 'Import nodes from Proxmox', + security: [{ BearerAuth: [] }] + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const site = await Site.findByPk(siteId); + + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const { apiUrl, username, password, tlsVerify } = request.body; + const tokenId = username; + const secret = password; + + try { + const tempNode = Node.build({ + name: 'temp', + apiUrl, + tokenId, + secret, + tlsVerify: tlsVerify !== 'false' + }); + + const client = await tempNode.api(); + const nodes = await client.nodes(); + + const nodesWithIp = await Promise.all(nodes.map(async (n) => { + let ipv4Address = null; + let imageStorage = 'local'; + let volumeStorage = 'local-lvm'; + + try { + const networkInterfaces = await client.nodeNetwork(n.node); + const primaryInterface = networkInterfaces.find(iface => + iface.iface === 'vmbr0' || (iface.type === 'bridge' && iface.active) + ); + ipv4Address = primaryInterface?.address || null; + } catch (err) { + fastify.log.error(`Failed to fetch network info for node ${n.node}:`, err.message); + } + + try { + const storages = await client.datastores(n.node, 'vztmpl', true); + if (storages.length > 0) { + const largest = storages.reduce((max, s) => (s.total > max.total ? s : max), storages[0]); + imageStorage = largest.storage; + } + } catch (err) { + fastify.log.error(`Failed to fetch storages for node ${n.node}:`, err.message); + } + + try { + const storages = await client.datastores(n.node, 'rootdir', true); + if (storages.length > 0) { + const largest = storages.reduce((max, s) => (s.total > max.total ? s : max), storages[0]); + volumeStorage = largest.storage; + } + } catch (err) { + fastify.log.error(`Failed to fetch volume storages for node ${n.node}:`, err.message); + } + + return { + name: n.node, + ipv4Address, + apiUrl, + tokenId, + secret, + tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true', + imageStorage, + volumeStorage, + siteId + }; + })); + + const importedNodes = await Node.bulkCreate(nodesWithIp); + + const containerList = await client.clusterResources('lxc'); + const containers = await Promise.all(containerList.map(async (c) => { + const config = await client.lxcConfig(c.node, c.vmid); + return { + hostname: config.hostname, + username: request.session.user, + nodeId: importedNodes.find(n => n.name === c.node).id, + siteId, + containerId: c.vmid, + macAddress: config.net0.match(/hwaddr=([0-9A-Fa-f:]+)/)[1], + ipv4Address: config.net0.match(/ip=([^,]+)/)[1].split('/')[0], + status: 'running' + }; + })); + await Container.bulkCreate(containers); + + if (request.isApiRequest()) { + return { success: true, nodesCount: importedNodes.length, containersCount: containers.length }; + } + + return reply.redirect(`/sites/${siteId}/nodes`); + } catch (err) { + fastify.log.error('Import error:', err); + if (request.isApiRequest()) { + return reply.code(400).send({ error: `Failed to import nodes: ${err.message}` }); + } + request.flash('error', `Failed to import nodes: ${err.message}`); + return reply.redirect(`/sites/${siteId}/nodes/import`); + } + }); + + // PUT /:id - Update a node + fastify.put('/:id', { + schema: { + tags: ['Nodes'], + summary: 'Update a node', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + siteId: { type: 'integer' }, + id: { type: 'integer' } + }, + required: ['siteId', 'id'] + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const nodeId = parseInt(request.params.id, 10); + + try { + const site = await Site.findByPk(siteId); + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const node = await Node.findOne({ where: { id: nodeId, siteId } }); + + if (!node) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Node not found' }); + } + request.flash('error', 'Node not found'); + return reply.redirect(`/sites/${siteId}/nodes`); + } + + const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage } = request.body; + + const updateData = { + name, + ipv4Address: ipv4Address || null, + apiUrl: apiUrl || null, + tokenId: tokenId || null, + tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true', + imageStorage: imageStorage || 'local', + volumeStorage: volumeStorage || 'local-lvm' + }; + + if (secret && secret.trim() !== '') { + updateData.secret = secret; + } + + await node.update(updateData); + + if (request.isApiRequest()) { + return { success: true, message: `Node ${name} updated successfully` }; + } + + request.flash('success', `Node ${name} updated successfully`); + return reply.redirect(`/sites/${siteId}/nodes`); + } catch (err) { + fastify.log.error('Error updating node:', err); + if (request.isApiRequest()) { + return reply.code(400).send({ error: `Failed to update node: ${err.message}` }); + } + request.flash('error', `Failed to update node: ${err.message}`); + return reply.redirect(`/sites/${siteId}/nodes/${nodeId}/edit`); + } + }); + + // GET /:id/storages - Get storages supporting CT templates + fastify.get('/:id/storages', { + schema: { + tags: ['Nodes'], + summary: 'Get node storages', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + siteId: { type: 'integer' }, + id: { type: 'integer' } + }, + required: ['siteId', 'id'] + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const nodeId = parseInt(request.params.id, 10); + + try { + const node = await Node.findOne({ where: { id: nodeId, siteId } }); + + if (!node || !node.apiUrl || !node.tokenId || !node.secret) { + return []; + } + + const client = await node.api(); + const storages = await client.datastores(node.name, 'vztmpl', true); + + return storages.map(s => ({ + name: s.storage, + total: s.total, + available: s.avail + })); + } catch (err) { + fastify.log.error('Error fetching storages:', err.message); + return []; + } + }); + + // DELETE /:id - Delete a node + fastify.delete('/:id', { + schema: { + tags: ['Nodes'], + summary: 'Delete a node', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + siteId: { type: 'integer' }, + id: { type: 'integer' } + }, + required: ['siteId', 'id'] + } + } + }, async (request, reply) => { + const siteId = getSiteId(request); + const nodeId = parseInt(request.params.id, 10); + + try { + const site = await Site.findByPk(siteId); + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const node = await Node.findOne({ + where: { id: nodeId, siteId }, + include: [{ model: Container, as: 'containers' }] + }); + + if (!node) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Node not found' }); + } + request.flash('error', 'Node not found'); + return reply.redirect(`/sites/${siteId}/nodes`); + } + + if (node.containers && node.containers.length > 0) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: `Cannot delete node ${node.name}: ${node.containers.length} container(s) still reference this node` }); + } + request.flash('error', `Cannot delete node ${node.name}: ${node.containers.length} container(s) still reference this node`); + return reply.redirect(`/sites/${siteId}/nodes`); + } + + const nodeName = node.name; + await node.destroy(); + + if (request.isApiRequest()) { + return reply.code(204).send(); + } + + request.flash('success', `Node ${nodeName} deleted successfully`); + return reply.redirect(`/sites/${siteId}/nodes`); + } catch (err) { + fastify.log.error('Error deleting node:', err); + if (request.isApiRequest()) { + return reply.code(500).send({ error: `Failed to delete node: ${err.message}` }); + } + request.flash('error', `Failed to delete node: ${err.message}`); + return reply.redirect(`/sites/${siteId}/nodes`); + } + }); +} + +module.exports = nodesRoutes; diff --git a/create-a-container/routes/register.js b/create-a-container/routes/register.js new file mode 100644 index 00000000..5dea0507 --- /dev/null +++ b/create-a-container/routes/register.js @@ -0,0 +1,242 @@ +const QRCode = require('qrcode'); +const { User, InviteToken, Setting } = require('../models'); +const { sendPushNotificationInvite } = require('../utils/push-notification-invite'); + +async function registerRoutes(fastify, options) { + // GET / - Display registration form + fastify.get('/', { + schema: { + tags: ['Authentication'], + summary: 'Registration page', + description: 'Display registration form', + querystring: { + type: 'object', + properties: { + token: { type: 'string', description: 'Invitation token' } + } + } + } + }, async (request, reply) => { + const { token } = request.query; + let inviteEmail = null; + let validToken = null; + + // If token provided, validate it and extract email + if (token) { + const inviteToken = await InviteToken.validateToken(token); + if (inviteToken) { + inviteEmail = inviteToken.email; + validToken = token; + } else { + request.flash('error', 'Invalid or expired invitation link. Please request a new invitation.'); + } + } + + if (request.isApiRequest()) { + return { + inviteEmail, + inviteToken: validToken, + valid: !!validToken + }; + } + + return reply.view('register', { + successMessages: reply.locals?.successMessages || [], + errorMessages: reply.locals?.errorMessages || [], + warningMessages: reply.locals?.warningMessages || [], + inviteEmail, + inviteToken: validToken + }); + }); + + // GET /success - Display QR code after invite-token registration + fastify.get('/success', { + schema: { + tags: ['Authentication'], + summary: 'Registration success page', + querystring: { + type: 'object', + properties: { + token: { type: 'string' } + } + } + } + }, async (request, reply) => { + const { token } = request.query; + + if (!token) { + return reply.redirect('/login'); + } + + const notificationUrl = await Setting.get('push_notification_url'); + if (!notificationUrl?.trim()) { + return reply.redirect('/login'); + } + + const inviteUrl = `${notificationUrl.trim()}/register?token=${encodeURIComponent(token)}`; + const qrCodeDataUri = await QRCode.toDataURL(inviteUrl, { width: 256 }); + + if (request.isApiRequest()) { + return { qrCodeDataUri, inviteUrl }; + } + + return reply.view('register-success', { qrCodeDataUri, inviteUrl }); + }); + + // POST / - Handle registration submission + fastify.post('/', { + schema: { + tags: ['Authentication'], + summary: 'Register new user', + description: 'Create a new user account', + body: { + type: 'object', + properties: { + uid: { type: 'string', minLength: 1 }, + givenName: { type: 'string', minLength: 1 }, + sn: { type: 'string', minLength: 1 }, + mail: { type: 'string', format: 'email' }, + userPassword: { type: 'string', minLength: 8 }, + inviteToken: { type: 'string' } + }, + required: ['uid', 'givenName', 'sn', 'mail', 'userPassword'] + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + redirect: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const { inviteToken } = request.body; + let isInvitedUser = false; + let validatedInvite = null; + + // If invite token provided, validate it matches the email + if (inviteToken) { + validatedInvite = await InviteToken.validateToken(inviteToken); + if (!validatedInvite) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Invalid or expired invitation link. Please request a new invitation.' }); + } + request.flash('error', 'Invalid or expired invitation link. Please request a new invitation.'); + return reply.redirect('/register'); + } + + // Ensure email matches the invite + const submittedEmail = request.body.mail.toLowerCase().trim(); + if (submittedEmail !== validatedInvite.email) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Email address does not match the invitation.' }); + } + request.flash('error', 'Email address does not match the invitation.'); + return reply.redirect(`/register?token=${inviteToken}`); + } + + isInvitedUser = true; + } + + // Determine user status + let status; + let isFirstUser = false; + if (await User.count() === 0) { + status = 'active'; + isFirstUser = true; + } else if (isInvitedUser) { + status = 'active'; + } else { + status = 'pending'; + } + + const givenName = request.body.givenName.trim(); + const sn = request.body.sn.trim(); + + const userParams = { + uidNumber: await User.nextUidNumber(), + uid: request.body.uid, + sn, + givenName, + mail: request.body.mail, + userPassword: request.body.userPassword, + status, + cn: `${givenName} ${sn}`, + homeDirectory: `/home/${request.body.uid}`, + }; + + try { + await User.create(userParams); + + // Mark invite token as used + if (validatedInvite) { + await validatedInvite.markAsUsed(); + } + + if (isInvitedUser) { + const inviteResult = await sendPushNotificationInvite(userParams); + + if (inviteResult?.success && inviteResult.inviteUrl) { + let validUrl = false; + try { + const parsed = new URL(inviteResult.inviteUrl); + validUrl = parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { /* invalid URL */ } + + if (validUrl) { + const inviteToken2fa = new URL(inviteResult.inviteUrl).searchParams.get('token'); + if (inviteToken2fa) { + if (request.isApiRequest()) { + return { success: true, message: 'Account created', redirect: `/register/success?token=${encodeURIComponent(inviteToken2fa)}` }; + } + return reply.redirect(303, `/register/success?token=${encodeURIComponent(inviteToken2fa)}`); + } + } + inviteResult.error = 'Invalid invite URL returned by 2FA service'; + } + + if (inviteResult?.error) { + request.flash('warning', `Account created, but 2FA invite failed: ${inviteResult.error}`); + } + request.flash('success', 'Account created successfully! You can now log in.'); + } else if (isFirstUser) { + request.flash('success', 'Admin account created successfully! You can now log in.'); + } else { + request.flash('success', 'Account registered successfully. You will be notified via email once approved.'); + } + + if (request.isApiRequest()) { + return { success: true, message: 'Account created successfully', redirect: '/login' }; + } + return reply.redirect('/login'); + } catch (err) { + fastify.log.error('Registration error:', err); + + let errorMessage = 'Registration failed: ' + err.message; + + if (err.name === 'SequelizeUniqueConstraintError' && err.errors && err.errors.length > 0) { + const field = err.errors[0]?.path; + if (field === 'uid') { + errorMessage = 'This username is already registered. Please choose a different username or login with your existing account.'; + } else if (field === 'mail') { + errorMessage = 'This email address is already registered. Please use a different email or login with your existing account.'; + } else { + errorMessage = 'A user with these details is already registered. Please login with your existing account.'; + } + } + + if (request.isApiRequest()) { + return reply.code(400).send({ error: errorMessage }); + } + + request.flash('error', errorMessage); + const redirectUrl = inviteToken ? `/register?token=${inviteToken}` : '/register'; + return reply.redirect(redirectUrl); + } + }); +} + +module.exports = registerRoutes; diff --git a/create-a-container/routes/reset-password.js b/create-a-container/routes/reset-password.js new file mode 100644 index 00000000..82b4d378 --- /dev/null +++ b/create-a-container/routes/reset-password.js @@ -0,0 +1,248 @@ +const { Op } = require('sequelize'); +const { User, PasswordResetToken } = require('../models'); +const { sendPasswordResetEmail } = require('../utils/email'); + +async function resetPasswordRoutes(fastify, options) { + // GET / - Display the form to request password reset + fastify.get('/', { + schema: { + tags: ['Authentication'], + summary: 'Password reset request page', + description: 'Display form to request password reset' + } + }, async (request, reply) => { + if (request.isApiRequest()) { + return { message: 'Submit username or email to request password reset' }; + } + + return reply.view('reset-password/request', { + successMessages: reply.locals?.successMessages || [], + errorMessages: reply.locals?.errorMessages || [] + }); + }); + + // POST / - Handle password reset request + fastify.post('/', { + schema: { + tags: ['Authentication'], + summary: 'Request password reset', + description: 'Send password reset email to user', + body: { + type: 'object', + properties: { + usernameOrEmail: { type: 'string', minLength: 1 } + }, + required: ['usernameOrEmail'] + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const { usernameOrEmail } = request.body; + + if (!usernameOrEmail || usernameOrEmail.trim() === '') { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Please enter your username or email address' }); + } + request.flash('error', 'Please enter your username or email address'); + return reply.redirect('/reset-password'); + } + + try { + const user = await User.findOne({ + where: { + [Op.or]: [ + { uid: usernameOrEmail.trim() }, + { mail: usernameOrEmail.trim() } + ] + } + }); + + if (!user) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/reset-password'); + } + + // Generate reset token + const { token } = await PasswordResetToken.generateToken(user.uidNumber); + + // Build reset URL + const resetUrl = `${request.protocol}://${request.hostname}/reset-password/${token}`; + + // Send email + try { + await sendPasswordResetEmail(user.mail, user.uid, resetUrl); + + if (request.isApiRequest()) { + return { success: true, message: 'Password reset instructions have been sent to your email address' }; + } + request.flash('success', 'Password reset instructions have been sent to your email address'); + return reply.redirect('/login'); + } catch (emailError) { + fastify.log.error('Failed to send password reset email:', emailError); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Password reset failed, please contact an administrator' }); + } + request.flash('error', 'Password reset failed, please contact an administrator'); + return reply.redirect('/reset-password'); + } + } catch (error) { + fastify.log.error('Password reset error:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Password reset failed, please contact an administrator' }); + } + request.flash('error', 'Password reset failed, please contact an administrator'); + return reply.redirect('/reset-password'); + } + }); + + // GET /:token - Display password reset form with token + fastify.get('/:token', { + schema: { + tags: ['Authentication'], + summary: 'Password reset form', + description: 'Display password reset form with validated token', + params: { + type: 'object', + properties: { + token: { type: 'string' } + }, + required: ['token'] + } + } + }, async (request, reply) => { + const { token } = request.params; + + try { + const resetToken = await PasswordResetToken.validateToken(token); + + if (!resetToken) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Invalid or expired password reset link' }); + } + request.flash('error', 'Invalid or expired password reset link'); + return reply.redirect('/login'); + } + + if (request.isApiRequest()) { + return { valid: true, username: resetToken.user.uid }; + } + + return reply.view('reset-password/reset', { + token, + username: resetToken.user.uid, + successMessages: reply.locals?.successMessages || [], + errorMessages: reply.locals?.errorMessages || [] + }); + } catch (error) { + fastify.log.error('Password reset token validation error:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Password reset failed, please contact an administrator' }); + } + request.flash('error', 'Password reset failed, please contact an administrator'); + return reply.redirect('/login'); + } + }); + + // POST /:token - Handle password reset + fastify.post('/:token', { + schema: { + tags: ['Authentication'], + summary: 'Reset password', + description: 'Set new password using reset token', + params: { + type: 'object', + properties: { + token: { type: 'string' } + }, + required: ['token'] + }, + body: { + type: 'object', + properties: { + password: { type: 'string', minLength: 8 }, + confirmPassword: { type: 'string', minLength: 8 } + }, + required: ['password', 'confirmPassword'] + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const { token } = request.params; + const { password, confirmPassword } = request.body; + + // Validate passwords + if (!password || !confirmPassword) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Please enter and confirm your new password' }); + } + request.flash('error', 'Please enter and confirm your new password'); + return reply.redirect(`/reset-password/${token}`); + } + + if (password !== confirmPassword) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Passwords do not match' }); + } + request.flash('error', 'Passwords do not match'); + return reply.redirect(`/reset-password/${token}`); + } + + if (password.length < 8) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Password must be at least 8 characters long' }); + } + request.flash('error', 'Password must be at least 8 characters long'); + return reply.redirect(`/reset-password/${token}`); + } + + try { + const resetToken = await PasswordResetToken.validateToken(token); + + if (!resetToken) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Invalid or expired password reset link' }); + } + request.flash('error', 'Invalid or expired password reset link'); + return reply.redirect('/login'); + } + + const user = resetToken.user; + await user.setPassword(password); + await resetToken.markAsUsed(); + + if (request.isApiRequest()) { + return { success: true, message: 'Your password has been reset successfully. Please log in with your new password.' }; + } + request.flash('success', 'Your password has been reset successfully. Please log in with your new password.'); + return reply.redirect('/login'); + } catch (error) { + fastify.log.error('Password reset error:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Password reset failed, please contact an administrator' }); + } + request.flash('error', 'Password reset failed, please contact an administrator'); + return reply.redirect(`/reset-password/${token}`); + } + }); +} + +module.exports = resetPasswordRoutes; diff --git a/create-a-container/routes/settings.js b/create-a-container/routes/settings.js new file mode 100644 index 00000000..72624df0 --- /dev/null +++ b/create-a-container/routes/settings.js @@ -0,0 +1,114 @@ +const { Setting } = require('../models'); + +async function settingsRoutes(fastify, options) { + // Apply auth to all routes - admin only + fastify.addHook('preHandler', fastify.requireAuth); + fastify.addHook('preHandler', fastify.requireAdmin); + + // GET / - Display settings page + fastify.get('/', { + schema: { + tags: ['Settings'], + summary: 'Get system settings', + description: 'Returns system settings (admin only)', + security: [{ BearerAuth: [] }], + response: { + 200: { + description: 'Settings', + type: 'object', + properties: { + pushNotificationUrl: { type: 'string' }, + pushNotificationEnabled: { type: 'boolean' }, + pushNotificationApiKey: { type: 'string' }, + smtpUrl: { type: 'string' }, + smtpNoreplyAddress: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const settings = await Setting.getMultiple([ + 'push_notification_url', + 'push_notification_enabled', + 'push_notification_api_key', + 'smtp_url', + 'smtp_noreply_address' + ]); + + const data = { + pushNotificationUrl: settings.push_notification_url || '', + pushNotificationEnabled: settings.push_notification_enabled === 'true', + pushNotificationApiKey: settings.push_notification_api_key || '', + smtpUrl: settings.smtp_url || '', + smtpNoreplyAddress: settings.smtp_noreply_address || '' + }; + + if (request.isApiRequest()) { + return data; + } + + return reply.view('settings/index', { ...data, req: request }); + }); + + // POST / - Update settings + fastify.post('/', { + schema: { + tags: ['Settings'], + summary: 'Update system settings', + description: 'Updates system settings (admin only)', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + push_notification_url: { type: 'string' }, + push_notification_enabled: { type: 'string' }, + push_notification_api_key: { type: 'string' }, + smtp_url: { type: 'string' }, + smtp_noreply_address: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const { + push_notification_url, + push_notification_enabled, + push_notification_api_key, + smtp_url, + smtp_noreply_address + } = request.body || {}; + + const enabled = push_notification_enabled === 'on' || push_notification_enabled === 'true'; + + if (enabled && (!push_notification_url || push_notification_url.trim() === '')) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Push notification URL is required when push notifications are enabled' }); + } + request.flash('error', 'Push notification URL is required when push notifications are enabled'); + return reply.redirect('/settings'); + } + + await Setting.set('push_notification_url', push_notification_url || ''); + await Setting.set('push_notification_enabled', enabled ? 'true' : 'false'); + await Setting.set('push_notification_api_key', push_notification_api_key || ''); + await Setting.set('smtp_url', smtp_url || ''); + await Setting.set('smtp_noreply_address', smtp_noreply_address || ''); + + if (request.isApiRequest()) { + return { success: true, message: 'Settings saved successfully' }; + } + + request.flash('success', 'Settings saved successfully'); + return reply.redirect('/settings'); + }); +} + +module.exports = settingsRoutes; diff --git a/create-a-container/routes/sites.js b/create-a-container/routes/sites.js new file mode 100644 index 00000000..4927ddcd --- /dev/null +++ b/create-a-container/routes/sites.js @@ -0,0 +1,380 @@ +const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain } = require('../models'); + +// Shared query for dnsmasq endpoints +async function loadDnsmasqSite(siteId) { + return Site.findByPk(siteId, { + include: [{ + model: Node, + as: 'nodes', + include: [{ + model: Container, + as: 'containers', + where: { status: 'running' }, + required: false, + attributes: ['macAddress', 'ipv4Address', 'hostname'] + }] + }] + }); +} + +async function sitesRoutes(fastify, options) { + const DNSMASQ_TEMPLATES = ['conf', 'dhcp-hosts', 'hosts', 'dhcp-opts', 'servers']; + + // GET /:siteId/dnsmasq/:file - Dnsmasq configuration files (localhost or admin) + fastify.get('/:siteId/dnsmasq/:file', { + preHandler: [fastify.requireLocalhostOrAdmin], + schema: { + tags: ['Sites'], + summary: 'Get dnsmasq configuration', + params: { + type: 'object', + properties: { + siteId: { type: 'integer' }, + file: { type: 'string', enum: DNSMASQ_TEMPLATES } + }, + required: ['siteId', 'file'] + } + } + }, async (request, reply) => { + const { file } = request.params; + if (!DNSMASQ_TEMPLATES.includes(file)) { + return reply.code(404).send('Not found'); + } + + const site = await loadDnsmasqSite(parseInt(request.params.siteId, 10)); + if (!site) { + return reply.code(404).send('Site not found'); + } + + reply.header('Content-Type', 'text/plain'); + return reply.view(`dnsmasq/${file}`, { site }); + }); + + // GET /:siteId/nginx - Endpoint for nginx configuration + fastify.get('/:siteId/nginx', { + preHandler: [fastify.requireLocalhostOrAdmin], + schema: { + tags: ['Sites'], + summary: 'Get nginx configuration', + params: { + type: 'object', + properties: { + siteId: { type: 'integer' } + }, + required: ['siteId'] + } + } + }, async (request, reply) => { + const siteId = parseInt(request.params.siteId, 10); + + const site = await Site.findByPk(siteId, { + include: [{ + model: Node, + as: 'nodes', + include: [{ + model: Container, + as: 'containers', + where: { status: 'running' }, + required: false, + include: [{ + model: Service, + as: 'services', + include: [ + { model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] }, + { model: TransportService, as: 'transportService' } + ] + }] + }] + }, { + model: ExternalDomain, + as: 'externalDomains' + }] + }); + + // Flatten services + const allServices = []; + site?.nodes?.forEach(node => { + node?.containers?.forEach(container => { + container?.services?.forEach(service => { + service.Container = container; + allServices.push(service); + }); + }); + }); + + const httpServices = allServices.filter(s => s.type === 'http'); + const streamServices = allServices.filter(s => s.type === 'transport'); + + const usedDomainIds = new Set(); + httpServices.forEach(s => { + if (s.httpService?.externalDomain?.id) usedDomainIds.add(s.httpService.externalDomain.id); + }); + (site?.externalDomains || []).forEach(d => usedDomainIds.add(d.id)); + const externalDomains = await ExternalDomain.findAll({ where: { id: [...usedDomainIds] } }); + + reply.header('Content-Type', 'text/plain'); + return reply.view('nginx-conf', { httpServices, streamServices, externalDomains }); + }); + + // Apply auth to all routes below this point + fastify.addHook('preHandler', fastify.requireAuth); + + // Register nested routers + fastify.register(require('./nodes'), { prefix: '/:siteId/nodes' }); + fastify.register(require('./containers'), { prefix: '/:siteId/containers' }); + + // GET / - List all sites + fastify.get('/', { + schema: { + tags: ['Sites'], + summary: 'List all sites', + security: [{ BearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + sites: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + internalDomain: { type: 'string' }, + dhcpRange: { type: 'string' }, + gateway: { type: 'string' }, + nodeCount: { type: 'integer' } + } + } + } + } + } + } + } + }, async (request, reply) => { + const sites = await Site.findAll({ + include: [{ model: Node, as: 'nodes', attributes: ['id', 'name'] }], + order: [['id', 'ASC']] + }); + + const rows = sites.map(s => ({ + id: s.id, + name: s.name, + internalDomain: s.internalDomain, + dhcpRange: s.dhcpRange, + gateway: s.gateway, + nodeCount: s.nodes ? s.nodes.length : 0 + })); + + if (request.isApiRequest()) { + return { sites: rows }; + } + + return reply.view('sites/index', { rows, req: request }); + }); + + // GET /new - Display form for creating a new site (admin only) + fastify.get('/new', { + preHandler: [fastify.requireAdmin] + }, async (request, reply) => { + return reply.view('sites/form', { + site: null, + isEdit: false, + req: request + }); + }); + + // GET /:id/edit - Display form for editing a site (admin only) + fastify.get('/:id/edit', { + preHandler: [fastify.requireAdmin], + schema: { + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const site = await Site.findByPk(request.params.id); + + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + return reply.view('sites/form', { + site, + isEdit: true, + req: request + }); + }); + + // POST / - Create a new site (admin only) + fastify.post('/', { + preHandler: [fastify.requireAdmin], + schema: { + tags: ['Sites'], + summary: 'Create a new site', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + internalDomain: { type: 'string' }, + dhcpRange: { type: 'string' }, + subnetMask: { type: 'string' }, + gateway: { type: 'string' }, + dnsForwarders: { type: 'string' }, + externalIp: { type: 'string' } + }, + required: ['name'] + } + } + }, async (request, reply) => { + try { + const { name, internalDomain, dhcpRange, subnetMask, gateway, dnsForwarders, externalIp } = request.body; + + const site = await Site.create({ + name, + internalDomain, + dhcpRange, + subnetMask, + gateway, + dnsForwarders, + externalIp: externalIp || null + }); + + if (request.isApiRequest()) { + return reply.code(201).send({ success: true, site: { id: site.id, name: site.name } }); + } + + request.flash('success', `Site ${name} created successfully`); + return reply.redirect('/sites'); + } catch (error) { + fastify.log.error('Error creating site:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to create site: ' + error.message }); + } + request.flash('error', 'Failed to create site: ' + error.message); + return reply.redirect('/sites/new'); + } + }); + + // PUT /:id - Update a site (admin only) + fastify.put('/:id', { + preHandler: [fastify.requireAdmin], + schema: { + tags: ['Sites'], + summary: 'Update a site', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + try { + const site = await Site.findByPk(request.params.id); + + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + const { name, internalDomain, dhcpRange, subnetMask, gateway, dnsForwarders, externalIp } = request.body; + + await site.update({ + name, + internalDomain, + dhcpRange, + subnetMask, + gateway, + dnsForwarders, + externalIp: externalIp || null + }); + + if (request.isApiRequest()) { + return { success: true, message: `Site ${name} updated successfully` }; + } + + request.flash('success', `Site ${name} updated successfully`); + return reply.redirect('/sites'); + } catch (error) { + fastify.log.error('Error updating site:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to update site: ' + error.message }); + } + request.flash('error', 'Failed to update site: ' + error.message); + return reply.redirect(`/sites/${request.params.id}/edit`); + } + }); + + // DELETE /:id - Delete a site (admin only) + fastify.delete('/:id', { + preHandler: [fastify.requireAdmin], + schema: { + tags: ['Sites'], + summary: 'Delete a site', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + try { + const site = await Site.findByPk(request.params.id, { + include: [{ model: Node, as: 'nodes' }] + }); + + if (!site) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'Site not found' }); + } + request.flash('error', 'Site not found'); + return reply.redirect('/sites'); + } + + if (site.nodes && site.nodes.length > 0) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Cannot delete site with associated nodes' }); + } + request.flash('error', 'Cannot delete site with associated nodes'); + return reply.redirect('/sites'); + } + + const siteName = site.name; + await site.destroy(); + + if (request.isApiRequest()) { + return reply.code(204).send(); + } + + request.flash('success', `Site ${siteName} deleted successfully`); + return reply.redirect('/sites'); + } catch (error) { + fastify.log.error('Error deleting site:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to delete site: ' + error.message }); + } + request.flash('error', 'Failed to delete site: ' + error.message); + return reply.redirect('/sites'); + } + }); +} + +module.exports = sitesRoutes; diff --git a/create-a-container/routes/users.js b/create-a-container/routes/users.js new file mode 100644 index 00000000..a52e6ff9 --- /dev/null +++ b/create-a-container/routes/users.js @@ -0,0 +1,397 @@ +const { User, Group, InviteToken, Setting } = require('../models'); +const { sendInviteEmail } = require('../utils/email'); +const { sendPushNotificationInvite } = require('../utils/push-notification-invite'); + +async function usersRoutes(fastify, options) { + // Apply auth and admin check to all routes + fastify.addHook('preHandler', fastify.requireAuth); + fastify.addHook('preHandler', fastify.requireAdmin); + + // GET / - List all users + fastify.get('/', { + schema: { + tags: ['Users'], + summary: 'List all users', + security: [{ BearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + uidNumber: { type: 'integer' }, + uid: { type: 'string' }, + cn: { type: 'string' }, + mail: { type: 'string' }, + status: { type: 'string' }, + groups: { type: 'string' }, + isAdmin: { type: 'boolean' } + } + } + } + } + } + } + } + }, async (request, reply) => { + const users = await User.findAll({ + include: [{ + association: 'groups', + attributes: ['gidNumber', 'cn', 'isAdmin'] + }], + order: [['uidNumber', 'ASC']] + }); + + const rows = users.map(u => ({ + uidNumber: u.uidNumber, + uid: u.uid, + cn: u.cn, + mail: u.mail, + status: u.status, + groups: u.groups ? u.groups.map(g => g.cn).join(', ') : '', + isAdmin: u.groups?.some(g => g.isAdmin) || false + })); + + if (request.isApiRequest()) { + return { users: rows }; + } + + return reply.view('users/index', { rows, req: request }); + }); + + // GET /new - Display form for creating a new user + fastify.get('/new', async (request, reply) => { + const groups = await Group.findAll({ + order: [['gidNumber', 'ASC']] + }); + + return reply.view('users/form', { + user: null, + groups, + isEdit: false, + req: request + }); + }); + + // GET /invite - Display form for inviting a user via email + fastify.get('/invite', async (request, reply) => { + return reply.view('users/invite', { req: request }); + }); + + // POST /invite - Send invitation email + fastify.post('/invite', { + schema: { + tags: ['Users'], + summary: 'Send user invitation', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + email: { type: 'string', format: 'email' } + }, + required: ['email'] + } + } + }, async (request, reply) => { + const { email } = request.body; + + if (!email || email.trim() === '') { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Please enter an email address' }); + } + request.flash('error', 'Please enter an email address'); + return reply.redirect('/users/invite'); + } + + const normalizedEmail = email.toLowerCase().trim(); + + try { + // Check if SMTP is configured + const settings = await Setting.getMultiple(['smtp_url']); + if (!settings.smtp_url || settings.smtp_url.trim() === '') { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'SMTP is not configured. Please configure SMTP settings before sending invitations.' }); + } + request.flash('error', 'SMTP is not configured. Please configure SMTP settings before sending invitations.'); + return reply.redirect('/users/invite'); + } + + // Check if email is already registered + const existingUser = await User.findOne({ where: { mail: normalizedEmail } }); + if (existingUser) { + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'A user with this email address is already registered' }); + } + request.flash('error', 'A user with this email address is already registered'); + return reply.redirect('/users/invite'); + } + + // Generate invite token (24-hour expiry) + const { token } = await InviteToken.generateToken(normalizedEmail, 24); + + // Build invite URL + const inviteUrl = `${request.protocol}://${request.hostname}/register?token=${token}`; + + // Send invite email + try { + await sendInviteEmail(normalizedEmail, inviteUrl); + if (request.isApiRequest()) { + return { success: true, message: `Invitation sent to ${normalizedEmail}` }; + } + request.flash('success', `Invitation sent to ${normalizedEmail}`); + return reply.redirect('/users'); + } catch (emailError) { + fastify.log.error('Failed to send invite email:', emailError); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to send invitation email. Please check SMTP settings.' }); + } + request.flash('error', 'Failed to send invitation email. Please check SMTP settings.'); + return reply.redirect('/users/invite'); + } + } catch (error) { + fastify.log.error('Invite error:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to send invitation: ' + error.message }); + } + request.flash('error', 'Failed to send invitation: ' + error.message); + return reply.redirect('/users/invite'); + } + }); + + // GET /:id/edit - Display form for editing an existing user + fastify.get('/:id/edit', { + schema: { + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const uidNumber = parseInt(request.params.id, 10); + + const user = await User.findByPk(uidNumber, { + include: [{ association: 'groups' }] + }); + + if (!user) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/users'); + } + + const groups = await Group.findAll({ + order: [['gidNumber', 'ASC']] + }); + + return reply.view('users/form', { + user, + groups, + isEdit: true, + req: request + }); + }); + + // POST / - Create a new user + fastify.post('/', { + schema: { + tags: ['Users'], + summary: 'Create a new user', + security: [{ BearerAuth: [] }], + body: { + type: 'object', + properties: { + uid: { type: 'string' }, + givenName: { type: 'string' }, + sn: { type: 'string' }, + mail: { type: 'string', format: 'email' }, + userPassword: { type: 'string' }, + status: { type: 'string', enum: ['active', 'pending', 'inactive'] }, + groupIds: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] } + }, + required: ['uid', 'givenName', 'sn', 'mail', 'userPassword'] + } + } + }, async (request, reply) => { + try { + const { uid, givenName: rawGivenName, sn: rawSn, mail, userPassword, status, groupIds } = request.body; + + const givenName = rawGivenName.trim(); + const sn = rawSn.trim(); + + const user = await User.create({ + uidNumber: await User.nextUidNumber(), + uid, + givenName, + sn, + cn: `${givenName} ${sn}`, + mail, + userPassword, + status: status || 'pending', + homeDirectory: `/home/${uid}`, + loginShell: '/bin/bash', + gidNumber: 2001 + }); + + // Add user to selected groups + if (groupIds) { + const gids = Array.isArray(groupIds) ? groupIds : [groupIds]; + const groups = await Group.findAll({ + where: { gidNumber: gids } + }); + await user.setGroups(groups); + } + + if (request.isApiRequest()) { + return reply.code(201).send({ success: true, user: { uidNumber: user.uidNumber, uid: user.uid } }); + } + request.flash('success', `User ${uid} created successfully`); + return reply.redirect('/users'); + } catch (error) { + fastify.log.error('Error creating user:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to create user: ' + error.message }); + } + request.flash('error', 'Failed to create user: ' + error.message); + return reply.redirect('/users/new'); + } + }); + + // PUT /:id - Update an existing user + fastify.put('/:id', { + schema: { + tags: ['Users'], + summary: 'Update a user', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const uidNumber = parseInt(request.params.id, 10); + + try { + const user = await User.findByPk(uidNumber); + + if (!user) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/users'); + } + + const { uid, givenName: rawGivenName, sn: rawSn, mail, userPassword, status, groupIds } = request.body; + + const givenName = rawGivenName.trim(); + const sn = rawSn.trim(); + + const previousStatus = user.status; + + user.uid = uid; + user.givenName = givenName; + user.sn = sn; + user.cn = `${givenName} ${sn}`; + user.mail = mail; + user.status = status || 'pending'; + user.homeDirectory = `/home/${uid}`; + + if (userPassword && userPassword.trim() !== '') { + user.userPassword = userPassword; + } + + await user.save(); + + // Send 2FA invite when user is first approved + if (previousStatus !== 'active' && user.status === 'active') { + const inviteResult = await sendPushNotificationInvite(user); + if (inviteResult && !inviteResult.success) { + request.flash('warning', `User approved but 2FA invite failed: ${inviteResult.error}`); + } + } + + // Update groups + if (groupIds) { + const gids = Array.isArray(groupIds) ? groupIds : [groupIds]; + const groups = await Group.findAll({ + where: { gidNumber: gids } + }); + await user.setGroups(groups); + } else { + await user.setGroups([]); + } + + if (request.isApiRequest()) { + return { success: true, message: `User ${uid} updated successfully` }; + } + request.flash('success', `User ${uid} updated successfully`); + return reply.redirect('/users'); + } catch (error) { + fastify.log.error('Error updating user:', error); + if (request.isApiRequest()) { + return reply.code(400).send({ error: 'Failed to update user: ' + error.message }); + } + request.flash('error', 'Failed to update user: ' + error.message); + return reply.redirect(`/users/${uidNumber}/edit`); + } + }); + + // DELETE /:id - Delete a user + fastify.delete('/:id', { + schema: { + tags: ['Users'], + summary: 'Delete a user', + security: [{ BearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' } + }, + required: ['id'] + } + } + }, async (request, reply) => { + const uidNumber = parseInt(request.params.id, 10); + + try { + const user = await User.findByPk(uidNumber); + + if (!user) { + if (request.isApiRequest()) { + return reply.code(404).send({ error: 'User not found' }); + } + request.flash('error', 'User not found'); + return reply.redirect('/users'); + } + + const username = user.uid; + await user.destroy(); + + if (request.isApiRequest()) { + return reply.code(204).send(); + } + request.flash('success', `User ${username} deleted successfully`); + return reply.redirect('/users'); + } catch (error) { + fastify.log.error('Error deleting user:', error); + if (request.isApiRequest()) { + return reply.code(500).send({ error: 'Failed to delete user: ' + error.message }); + } + request.flash('error', 'Failed to delete user: ' + error.message); + return reply.redirect('/users'); + } + }); +} + +module.exports = usersRoutes; diff --git a/create-a-container/server-fastify.js b/create-a-container/server-fastify.js new file mode 100644 index 00000000..68c628fb --- /dev/null +++ b/create-a-container/server-fastify.js @@ -0,0 +1,206 @@ +require('dotenv').config(); + +const path = require('path'); +const crypto = require('crypto'); +const Fastify = require('fastify'); +const { sequelize, SessionSecret } = require('./models'); + +// Function to get or create session secrets +async function getSessionSecrets() { + const secrets = await SessionSecret.findAll({ + order: [['createdAt', 'DESC']], + attributes: ['secret'] + }); + + if (secrets.length === 0) { + const newSecret = crypto.randomBytes(32).toString('hex'); + await SessionSecret.create({ secret: newSecret }); + console.log('Generated new session secret'); + return [newSecret]; + } + + return secrets.map(s => s.secret); +} + +async function buildApp(opts = {}) { + const app = Fastify({ + logger: { + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + transport: process.env.NODE_ENV !== 'production' ? { + target: 'pino-pretty', + options: { colorize: true } + } : undefined + }, + trustProxy: true, + ...opts + }); + + // --- Core Plugins --- + await app.register(require('@fastify/sensible')); + await app.register(require('@fastify/cors'), { + origin: process.env.CORS_ORIGIN || false, + credentials: true + }); + await app.register(require('@fastify/formbody')); + await app.register(require('@fastify/cookie')); + + // --- Session --- + const sessionSecrets = await getSessionSecrets(); + await app.register(require('@fastify/session'), { + secret: sessionSecrets[0], + cookie: { + secure: process.env.NODE_ENV === 'production', + maxAge: 24 * 60 * 60 * 1000 // 24 hours + }, + saveUninitialized: false + }); + + // --- Flash Messages Plugin --- + await app.register(require('./plugins/flash')); + + // --- Static Files --- + await app.register(require('@fastify/static'), { + root: path.join(__dirname, 'public'), + prefix: '/' + }); + + // --- View Engine (EJS) --- + await app.register(require('@fastify/view'), { + engine: { ejs: require('ejs') }, + root: path.join(__dirname, 'views'), + viewExt: 'ejs', + defaultContext: { + // Global template variables + } + }); + + // --- Rate Limiting --- + await app.register(require('@fastify/rate-limit'), { + max: 10, + timeWindow: 5 * 60 * 1000, // 5 minutes + skipOnError: true, + keyGenerator: (request) => request.ip + }); + + // --- Swagger/OpenAPI --- + await app.register(require('@fastify/swagger'), { + openapi: { + info: { + title: 'Create-a-Container API', + description: `REST API for managing containers, API keys, and jobs. + +## Authentication + +All API endpoints require authentication via an API key passed as a Bearer token: + +\`\`\` +Authorization: Bearer +\`\`\` + +Create API keys through \`POST /apikeys\` or the web UI at \`/apikeys/new\`. + +## Content Negotiation + +Routes serve both HTML and JSON. To receive JSON responses, set: + +\`\`\` +Accept: application/json +\`\`\` + +## Rate Limiting + +Failed requests (4xx/5xx) are rate-limited to **10 per 5-minute window** per IP.`, + version: '2.0.0', + license: { name: 'MIT' } + }, + servers: [{ url: '/', description: 'Current server' }], + tags: [ + { name: 'API Keys', description: 'Manage personal API keys for Bearer token authentication' }, + { name: 'Containers', description: 'Create, list, update, and delete containers within a site' }, + { name: 'Jobs', description: 'Monitor container creation and reconfiguration jobs' }, + { name: 'Nodes', description: 'Query node storage information' }, + { name: 'Sites', description: 'Manage sites and their configuration' }, + { name: 'Users', description: 'User management' }, + { name: 'Groups', description: 'Group management' }, + { name: 'Settings', description: 'System settings' } + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API key authentication' + } + } + } + } + }); + + await app.register(require('@fastify/swagger-ui'), { + routePrefix: '/api', + uiConfig: { + docExpansion: 'list', + deepLinking: true + } + }); + + // --- Auth Plugin --- + await app.register(require('./plugins/auth')); + + // --- Load Sites for Authenticated Users --- + await app.register(require('./plugins/load-sites')); + + // --- Version Info --- + const { getVersionInfo } = require('./utils'); + app.decorate('versionInfo', getVersionInfo()); + + // --- Routes --- + app.get('/', async (request, reply) => { + return reply.redirect('/sites'); + }); + + // Register route modules + await app.register(require('./routes/login'), { prefix: '/login' }); + await app.register(require('./routes/register'), { prefix: '/register' }); + await app.register(require('./routes/reset-password'), { prefix: '/reset-password' }); + await app.register(require('./routes/apikeys'), { prefix: '/apikeys' }); + await app.register(require('./routes/settings'), { prefix: '/settings' }); + await app.register(require('./routes/users'), { prefix: '/users' }); + await app.register(require('./routes/groups'), { prefix: '/groups' }); + await app.register(require('./routes/sites'), { prefix: '/sites' }); + await app.register(require('./routes/jobs'), { prefix: '/jobs' }); + await app.register(require('./routes/external-domains'), { prefix: '/external-domains' }); + + // Logout route + app.post('/logout', async (request, reply) => { + request.session.destroy(); + return reply.redirect('/'); + }); + + // --- MCP Server --- + await app.register(require('./plugins/mcp')); + + return app; +} + +// Start server if run directly +if (require.main === module) { + const PORT = process.env.PORT || 3000; + + buildApp().then(app => { + app.listen({ port: PORT, host: '0.0.0.0' }, (err, address) => { + if (err) { + app.log.error(err); + process.exit(1); + } + console.log(''); + console.log('='.repeat(50)); + console.log(` Fastify server ready at ${address}`); + console.log(` API docs at ${address}/api`); + console.log('='.repeat(50)); + console.log(''); + }); + }); +} + +module.exports = { buildApp }; diff --git a/create-a-container/server.js b/create-a-container/server.js index 252be879..dc8b4261 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -156,7 +156,13 @@ async function main() { // --- Routes --- const PORT = 3000; - app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`)); + app.listen(PORT, () => { + console.log(''); + console.log('='.repeat(50)); + console.log(' Server ready at http://localhost:' + PORT); + console.log('='.repeat(50)); + console.log(''); + }); // Handles logout app.post('/logout', (req, res) => { diff --git a/create-a-container/tests/quick-login.spec.js b/create-a-container/tests/quick-login.spec.js new file mode 100644 index 00000000..91dbd68f --- /dev/null +++ b/create-a-container/tests/quick-login.spec.js @@ -0,0 +1,72 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +test.describe('Quick Login and API Key', () => { + test.beforeEach(async ({ page }) => { + // Start from the login page + await page.goto('/login'); + }); + + test('should login as admin using quick login', async ({ page }) => { + // Click the quick login button (Login as Admin) + await page.getByRole('button', { name: 'Login as Admin' }).click(); + + // Should redirect to sites page + await expect(page).toHaveURL(/\/sites/); + + // Should show admin menu items + await expect(page.getByRole('link', { name: 'Users' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); + + // Should show New Site button (admin only) + await expect(page.getByRole('button', { name: 'New Site' })).toBeVisible(); + }); + + test('should login as standard user using quick login dropdown', async ({ page }) => { + // Click dropdown toggle + await page.getByRole('button', { name: '▼' }).click(); + + // Select "Login as Standard User" + await page.getByRole('link', { name: 'Login as Standard User' }).click(); + + // Click the button (now labeled "Login as Standard User") + await page.getByRole('button', { name: 'Login as Standard User' }).click(); + + // Should redirect to sites page + await expect(page).toHaveURL(/\/sites/); + + // Should NOT show admin menu items + await expect(page.getByRole('link', { name: 'Users' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Settings' })).not.toBeVisible(); + }); + + test('should create an API key after quick login', async ({ page }) => { + // Quick login as admin + await page.getByRole('button', { name: 'Login as Admin' }).click(); + await expect(page).toHaveURL(/\/sites/); + + // Navigate to API Keys + await page.getByRole('link', { name: 'API Keys' }).click(); + await expect(page).toHaveURL(/\/apikeys/); + + // Click "New API Key" button + await page.getByRole('button', { name: 'Create new API key' }).click(); + await expect(page).toHaveURL(/\/apikeys\/new/); + + // Fill in description + await page.getByRole('textbox', { name: 'Description' }).fill('Test API Key from Playwright'); + + // Generate the key + await page.getByRole('button', { name: 'Generate API Key' }).click(); + + // Should show success message with the API key + // The key is displayed only once, so we check for the success indication + await expect(page.getByText(/API key created/i).or(page.getByText(/Your new API key/i))).toBeVisible(); + + // Navigate back to API keys list + await page.getByRole('link', { name: 'API Keys' }).first().click(); + + // Should see the new key in the list (use first() since name may appear in multiple columns) + await expect(page.getByRole('cell', { name: 'Test API Key from Playwright' }).first()).toBeVisible(); + }); +}); diff --git a/create-a-container/views/login.ejs b/create-a-container/views/login.ejs index a241f220..bcedd878 100644 --- a/create-a-container/views/login.ejs +++ b/create-a-container/views/login.ejs @@ -34,6 +34,53 @@ <% }) %> <% } %> + + <% if (typeof noUsers !== 'undefined' && noUsers) { %> +
+ Welcome! No users exist yet. Register to create the first admin account. +
+ <% } %> + <% if (typeof showQuickLogin !== 'undefined' && showQuickLogin) { %> + +
+ + <% } %>