From 2fa50b001c2f7fb1c091efd1fd6e5c50f5f9f82e Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Sat, 21 Feb 2026 11:51:45 -0700 Subject: [PATCH] Merge changemaker-control-panel into v2 monorepo Absorbs the separate control-panel git repo as a subdirectory. Instances and backups directories excluded via .gitignore. Bunker Admin --- .gitignore | 4 + changemaker-control-panel | 1 - changemaker-control-panel/.env.example | 59 + changemaker-control-panel/.gitignore | 13 + changemaker-control-panel/CCP_PLAN.md | 605 ++++ changemaker-control-panel/Dockerfile.admin | 25 + changemaker-control-panel/Dockerfile.api | 20 + changemaker-control-panel/admin/index.html | 13 + .../admin/package-lock.json | 3012 +++++++++++++++++ changemaker-control-panel/admin/package.json | 30 + changemaker-control-panel/admin/src/App.tsx | 72 + .../admin/src/components/AppLayout.tsx | 181 + .../components/DiscoverInstancesDrawer.tsx | 373 ++ .../admin/src/components/InstanceCard.tsx | 127 + .../admin/src/components/LogViewer.tsx | 186 + .../admin/src/components/ProtectedRoute.tsx | 22 + .../src/components/ServiceHealthGrid.tsx | 138 + .../admin/src/lib/api.ts | 92 + changemaker-control-panel/admin/src/main.tsx | 9 + .../admin/src/pages/AuditLogPage.tsx | 271 ++ .../admin/src/pages/BackupsPage.tsx | 259 ++ .../admin/src/pages/CreateWizardPage.tsx | 489 +++ .../admin/src/pages/DashboardPage.tsx | 227 ++ .../admin/src/pages/InstanceDetailPage.tsx | 1083 ++++++ .../admin/src/pages/InstanceListPage.tsx | 283 ++ .../admin/src/pages/LoginPage.tsx | 76 + .../admin/src/pages/RegisterInstancePage.tsx | 298 ++ .../admin/src/pages/SettingsPage.tsx | 109 + .../admin/src/stores/auth.store.ts | 142 + .../admin/src/types/api.ts | 135 + .../admin/src/vite-env.d.ts | 1 + changemaker-control-panel/admin/tsconfig.json | 25 + .../admin/vite.config.ts | 21 + .../api/package-lock.json | 2464 ++++++++++++++ changemaker-control-panel/api/package.json | 45 + .../prisma/migrations/0001_init/migration.sql | 203 ++ .../migration.sql | 3 + .../migration.sql | 3 + .../api/prisma/migrations/migration_lock.toml | 3 + .../api/prisma/schema.prisma | 232 ++ changemaker-control-panel/api/prisma/seed.ts | 49 + .../api/src/config/env.ts | 80 + .../api/src/config/redis.ts | 14 + .../api/src/lib/prisma.ts | 3 + .../api/src/middleware/auth.ts | 51 + .../api/src/middleware/error-handler.ts | 51 + .../api/src/middleware/validate.ts | 16 + .../api/src/modules/audit/audit.routes.ts | 30 + .../api/src/modules/audit/audit.service.ts | 43 + .../api/src/modules/auth/auth.routes.ts | 59 + .../api/src/modules/auth/auth.schemas.ts | 18 + .../api/src/modules/auth/auth.service.ts | 131 + .../api/src/modules/backups/backup.routes.ts | 71 + .../api/src/modules/health/health.routes.ts | 46 + .../src/modules/instances/instances.routes.ts | 278 ++ .../modules/instances/instances.schemas.ts | 82 + .../modules/instances/instances.service.ts | 607 ++++ .../api/src/modules/instances/provisioner.ts | 197 ++ .../src/modules/settings/settings.routes.ts | 43 + changemaker-control-panel/api/src/server.ts | 66 + .../api/src/services/backup.service.ts | 313 ++ .../api/src/services/discovery.service.ts | 388 +++ .../api/src/services/docker.service.ts | 351 ++ .../api/src/services/health.service.ts | 191 ++ .../api/src/services/port-allocator.ts | 94 + .../api/src/services/secret-generator.ts | 73 + .../api/src/services/template-engine.ts | 227 ++ .../api/src/utils/encryption.ts | 37 + .../api/src/utils/logger.ts | 13 + changemaker-control-panel/api/tsconfig.json | 24 + changemaker-control-panel/docker-compose.yml | 91 + changemaker-control-panel/setup.sh | 91 + .../configs/pangolin/resources.yml.hbs | 56 + .../configs/prometheus/prometheus.yml.hbs | 22 + .../templates/docker-compose.yml.hbs | 658 ++++ changemaker-control-panel/templates/env.hbs | 223 ++ .../templates/nginx/conf.d/api.conf.hbs | 2 + .../templates/nginx/conf.d/default.conf.hbs | 81 + .../templates/nginx/conf.d/services.conf.hbs | 243 ++ .../templates/nginx/nginx.conf | 47 + 80 files changed, 16513 insertions(+), 1 deletion(-) delete mode 160000 changemaker-control-panel create mode 100644 changemaker-control-panel/.env.example create mode 100644 changemaker-control-panel/.gitignore create mode 100644 changemaker-control-panel/CCP_PLAN.md create mode 100644 changemaker-control-panel/Dockerfile.admin create mode 100644 changemaker-control-panel/Dockerfile.api create mode 100644 changemaker-control-panel/admin/index.html create mode 100644 changemaker-control-panel/admin/package-lock.json create mode 100644 changemaker-control-panel/admin/package.json create mode 100644 changemaker-control-panel/admin/src/App.tsx create mode 100644 changemaker-control-panel/admin/src/components/AppLayout.tsx create mode 100644 changemaker-control-panel/admin/src/components/DiscoverInstancesDrawer.tsx create mode 100644 changemaker-control-panel/admin/src/components/InstanceCard.tsx create mode 100644 changemaker-control-panel/admin/src/components/LogViewer.tsx create mode 100644 changemaker-control-panel/admin/src/components/ProtectedRoute.tsx create mode 100644 changemaker-control-panel/admin/src/components/ServiceHealthGrid.tsx create mode 100644 changemaker-control-panel/admin/src/lib/api.ts create mode 100644 changemaker-control-panel/admin/src/main.tsx create mode 100644 changemaker-control-panel/admin/src/pages/AuditLogPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/BackupsPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/DashboardPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/InstanceListPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/LoginPage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/RegisterInstancePage.tsx create mode 100644 changemaker-control-panel/admin/src/pages/SettingsPage.tsx create mode 100644 changemaker-control-panel/admin/src/stores/auth.store.ts create mode 100644 changemaker-control-panel/admin/src/types/api.ts create mode 100644 changemaker-control-panel/admin/src/vite-env.d.ts create mode 100644 changemaker-control-panel/admin/tsconfig.json create mode 100644 changemaker-control-panel/admin/vite.config.ts create mode 100644 changemaker-control-panel/api/package-lock.json create mode 100644 changemaker-control-panel/api/package.json create mode 100644 changemaker-control-panel/api/prisma/migrations/0001_init/migration.sql create mode 100644 changemaker-control-panel/api/prisma/migrations/20260220021454_add_is_registered/migration.sql create mode 100644 changemaker-control-panel/api/prisma/migrations/20260220041455_add_feature_flags/migration.sql create mode 100644 changemaker-control-panel/api/prisma/migrations/migration_lock.toml create mode 100644 changemaker-control-panel/api/prisma/schema.prisma create mode 100644 changemaker-control-panel/api/prisma/seed.ts create mode 100644 changemaker-control-panel/api/src/config/env.ts create mode 100644 changemaker-control-panel/api/src/config/redis.ts create mode 100644 changemaker-control-panel/api/src/lib/prisma.ts create mode 100644 changemaker-control-panel/api/src/middleware/auth.ts create mode 100644 changemaker-control-panel/api/src/middleware/error-handler.ts create mode 100644 changemaker-control-panel/api/src/middleware/validate.ts create mode 100644 changemaker-control-panel/api/src/modules/audit/audit.routes.ts create mode 100644 changemaker-control-panel/api/src/modules/audit/audit.service.ts create mode 100644 changemaker-control-panel/api/src/modules/auth/auth.routes.ts create mode 100644 changemaker-control-panel/api/src/modules/auth/auth.schemas.ts create mode 100644 changemaker-control-panel/api/src/modules/auth/auth.service.ts create mode 100644 changemaker-control-panel/api/src/modules/backups/backup.routes.ts create mode 100644 changemaker-control-panel/api/src/modules/health/health.routes.ts create mode 100644 changemaker-control-panel/api/src/modules/instances/instances.routes.ts create mode 100644 changemaker-control-panel/api/src/modules/instances/instances.schemas.ts create mode 100644 changemaker-control-panel/api/src/modules/instances/instances.service.ts create mode 100644 changemaker-control-panel/api/src/modules/instances/provisioner.ts create mode 100644 changemaker-control-panel/api/src/modules/settings/settings.routes.ts create mode 100644 changemaker-control-panel/api/src/server.ts create mode 100644 changemaker-control-panel/api/src/services/backup.service.ts create mode 100644 changemaker-control-panel/api/src/services/discovery.service.ts create mode 100644 changemaker-control-panel/api/src/services/docker.service.ts create mode 100644 changemaker-control-panel/api/src/services/health.service.ts create mode 100644 changemaker-control-panel/api/src/services/port-allocator.ts create mode 100644 changemaker-control-panel/api/src/services/secret-generator.ts create mode 100644 changemaker-control-panel/api/src/services/template-engine.ts create mode 100644 changemaker-control-panel/api/src/utils/encryption.ts create mode 100644 changemaker-control-panel/api/src/utils/logger.ts create mode 100644 changemaker-control-panel/api/tsconfig.json create mode 100644 changemaker-control-panel/docker-compose.yml create mode 100755 changemaker-control-panel/setup.sh create mode 100644 changemaker-control-panel/templates/configs/pangolin/resources.yml.hbs create mode 100644 changemaker-control-panel/templates/configs/prometheus/prometheus.yml.hbs create mode 100644 changemaker-control-panel/templates/docker-compose.yml.hbs create mode 100644 changemaker-control-panel/templates/env.hbs create mode 100644 changemaker-control-panel/templates/nginx/conf.d/api.conf.hbs create mode 100644 changemaker-control-panel/templates/nginx/conf.d/default.conf.hbs create mode 100644 changemaker-control-panel/templates/nginx/conf.d/services.conf.hbs create mode 100644 changemaker-control-panel/templates/nginx/nginx.conf diff --git a/.gitignore b/.gitignore index 361f5ede..31a98ad5 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ docker-compose.override.yml /logs/ /backups/ .upgrade.lock + +# Control Panel runtime data (managed deployments + backups) +/changemaker-control-panel/instances/ +/changemaker-control-panel/backups/ diff --git a/changemaker-control-panel b/changemaker-control-panel deleted file mode 160000 index d4cd2d2c..00000000 --- a/changemaker-control-panel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d4cd2d2cd5d2dd33d49c7d6feaed975741e0925a diff --git a/changemaker-control-panel/.env.example b/changemaker-control-panel/.env.example new file mode 100644 index 00000000..d7c1e6c5 --- /dev/null +++ b/changemaker-control-panel/.env.example @@ -0,0 +1,59 @@ +# ============================================================ +# Changemaker Control Panel — Environment Variables +# ============================================================ + +# Server +NODE_ENV=development +PORT=5000 +ADMIN_PORT=5100 + +# Database +DATABASE_URL=postgresql://ccp:ccp_secret@localhost:5480/ccp +CCP_POSTGRES_PASSWORD=ccp_secret + +# Redis +REDIS_URL=redis://:ccp_redis_secret@localhost:6399 +REDIS_PASSWORD=ccp_redis_secret + +# JWT Secrets (generate with: openssl rand -hex 32) +JWT_ACCESS_SECRET=change-me-to-a-random-64-char-hex-string-for-access-tokens!! +JWT_REFRESH_SECRET=change-me-to-a-random-64-char-hex-string-for-refresh-tokens! + +# Encryption key for secrets at rest (openssl rand -hex 32) +ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string-for-encryption-keys! + +# Initial Admin Account +INITIAL_ADMIN_EMAIL=admin@example.com +INITIAL_ADMIN_PASSWORD=ChangeMe2025!! + +# CORS +CORS_ORIGINS=http://localhost:5100 + +# Instance Management +# Absolute paths — auto-resolved by setup.sh (run ./setup.sh after cloning) +INSTANCES_BASE_PATH=/path/to/changemaker-control-panel/instances +CML_SOURCE_PATH=/path/to/changemaker.lite +CML_GIT_REPO=https://github.com/your-org/changemaker.lite.git +CML_GIT_BRANCH=v2 + +# Port Allocation Ranges +PORT_RANGE_API_START=14000 +PORT_RANGE_API_END=14999 +PORT_RANGE_ADMIN_START=13000 +PORT_RANGE_ADMIN_END=13999 +PORT_RANGE_POSTGRES_START=15400 +PORT_RANGE_POSTGRES_END=15499 +PORT_RANGE_NGINX_START=10000 +PORT_RANGE_NGINX_END=10999 + +# Pangolin (optional — for tunnel management) +PANGOLIN_API_URL= +PANGOLIN_API_KEY= +PANGOLIN_ORG_ID= + +# Health Checks (0 to disable automated checks) +HEALTH_CHECK_INTERVAL_MS=300000 + +# Backups (auto-resolved by setup.sh) +BACKUP_STORAGE_PATH=/path/to/changemaker-control-panel/backups +BACKUP_RETENTION_DAYS=30 diff --git a/changemaker-control-panel/.gitignore b/changemaker-control-panel/.gitignore new file mode 100644 index 00000000..3e16ff29 --- /dev/null +++ b/changemaker-control-panel/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +.vscode/ +*.tsbuildinfo + +# Instance data (managed deployments) +/instances/ + +# Backup storage +/backups/ diff --git a/changemaker-control-panel/CCP_PLAN.md b/changemaker-control-panel/CCP_PLAN.md new file mode 100644 index 00000000..c94ac04a --- /dev/null +++ b/changemaker-control-panel/CCP_PLAN.md @@ -0,0 +1,605 @@ +# Changemaker Control Panel (CCP) — Development Plan + +A multi-tenant management system for provisioning, monitoring, and operating multiple Changemaker Lite instances from a single dashboard. + +--- + +## Vision + +CCP replaces manual `git clone` / `docker compose up` / `.env` editing with a web UI that can: +- One-click provision a new Changemaker Lite instance (database, containers, config, tunnel) +- Monitor instance health across the fleet +- Start/stop/restart instances and individual services +- Back up and restore instance data +- Maintain a full audit trail of operator actions +- Manage Pangolin tunnels for production exposure + +--- + +## Architecture + +``` + ┌─────────────────────────┐ + │ CCP Admin GUI (5100) │ React + Vite + Ant Design + │ Dark theme, SPA │ Zustand auth store + └────────────┬─────────────┘ + │ /api/* proxy + ┌────────────▼─────────────┐ + │ CCP API (5000) │ Express + TypeScript + │ JWT auth, RBAC │ Prisma ORM → PostgreSQL + │ Docker socket access │ Winston logger + └────────────┬─────────────┘ + │ + ┌───────────┬───────────┼───────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + ccp-postgres ccp-redis Docker Socket /opt/ccp/ /var/backups/ + (port 5480) (port 6399) (.sock) instances ccp-instances +``` + +### Stack + +| Layer | Technology | Notes | +|-------|-----------|-------| +| API | Express 4, TypeScript 5, Node 20 | `express-async-errors` for async route handling | +| ORM | Prisma 6 + PostgreSQL 16 | 10 models, mapped table names | +| Auth | JWT (jsonwebtoken) + bcryptjs | 15min access / 7d refresh, atomic rotation | +| Encryption | AES-256-GCM (Node crypto) | Secrets at rest in `encrypted_secrets` column | +| Frontend | React 19, Vite 6, Ant Design 5 | Dark theme, Zustand state, axios interceptors | +| Docker | Docker CLI + socket API | compose up/down/ps/exec/logs via shell + HTTP socket | +| Templates | Handlebars | .env, docker-compose.yml, nginx configs rendered per-instance | +| Logging | Winston | JSON in production, colorized in development | +| Config | Zod schema validation | Fails fast on startup with clear error messages | + +### Key Design Decisions + +1. **Docker CLI over Docker SDK** — Shell out to `docker compose` commands rather than using dockerode. Simpler, matches what operators would run manually, and `docker compose` handles all the orchestration logic. + +2. **Shared PostgreSQL** — All CCP data in one database; each CML instance gets its own isolated PostgreSQL container with unique ports and passwords. + +3. **Port Range Allocation** — Four non-overlapping port ranges prevent conflicts: + - API: 14000-14999 + - Admin: 13000-13999 + - PostgreSQL: 15400-15499 + - Nginx: 10000-10999 + +4. **Async Provisioning** — Instance creation returns immediately; provisioning runs fire-and-forget with progress tracked via `status`/`statusMessage` fields. Frontend polls for updates. + +5. **Template-Based Config** — Handlebars templates render per-instance docker-compose.yml, .env, nginx configs, and Pangolin resources. This avoids complex string manipulation and keeps configs readable. + +--- + +## Database Schema (Prisma) + +``` +CcpUser ──< CcpRefreshToken + │ + ├──< AuditLog + │ +Instance ──< PortAllocation + │ + ├──< HealthCheck + ├──< Backup + └──< AuditLog + +CcpSetting (key-value) +``` + +### Models + +| Model | Purpose | Key Fields | +|-------|---------|------------| +| **CcpUser** | Control panel operators | email, password (bcrypt), role (SUPER_ADMIN/OPERATOR/VIEWER) | +| **CcpRefreshToken** | JWT refresh token storage | token (SHA-256 hash), expiresAt | +| **Instance** | Managed CML instance | slug, domain, basePath, composeProject, status, portConfig (JSON), encryptedSecrets, feature flags | +| **PortAllocation** | Port registry | port (unique), service name, instanceId | +| **HealthCheck** | Periodic health snapshots | status (HEALTHY/DEGRADED/UNHEALTHY/UNKNOWN), serviceStatus (JSON), totalServices, healthyServices, responseTimeMs | +| **Backup** | Backup records | status (PENDING→IN_PROGRESS→COMPLETED/FAILED), archivePath, sizeBytes, manifest (JSON) | +| **AuditLog** | Action trail | action (18 types), userId, instanceId, details (JSON), ipAddress | +| **CcpSetting** | Global key-value config | key (PK), value (JSON) | + +### Audit Actions (18 types) + +``` +INSTANCE_CREATE, INSTANCE_UPDATE, INSTANCE_DELETE, +INSTANCE_START, INSTANCE_STOP, INSTANCE_RESTART, INSTANCE_UPGRADE, +BACKUP_CREATE, BACKUP_DELETE, +PANGOLIN_SETUP, PANGOLIN_SYNC, +USER_LOGIN, USER_CREATE, USER_UPDATE, USER_DELETE, +SETTINGS_UPDATE +``` + +--- + +## API Endpoints + +### Authentication +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/auth/login` | Public | Login, returns JWT pair | +| POST | `/api/auth/refresh` | Public | Rotate refresh token | +| POST | `/api/auth/logout` | Public | Revoke refresh token | +| GET | `/api/auth/me` | Authenticated | Current user profile | + +### Instances — CRUD +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/instances` | Authenticated | List all instances | +| GET | `/api/instances/:id` | Authenticated | Instance detail (includes health + backups) | +| POST | `/api/instances` | SUPER_ADMIN, OPERATOR | Create instance (triggers async provisioning) | +| PUT | `/api/instances/:id` | SUPER_ADMIN, OPERATOR | Update instance config | +| DELETE | `/api/instances/:id` | SUPER_ADMIN | Delete instance (stops containers, removes files) | +| GET | `/api/instances/:id/secrets` | SUPER_ADMIN | Decrypt and return instance secrets | + +### Instances — Lifecycle +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/instances/:id/provision` | SUPER_ADMIN, OPERATOR | Retry provisioning | +| POST | `/api/instances/:id/start` | SUPER_ADMIN, OPERATOR | Start all containers | +| POST | `/api/instances/:id/stop` | SUPER_ADMIN, OPERATOR | Stop all containers | +| POST | `/api/instances/:id/restart` | SUPER_ADMIN, OPERATOR | Restart (optionally `?service=api`) | + +### Instances — Services & Logs +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/instances/:id/services` | Authenticated | Container status via `docker compose ps` | +| GET | `/api/instances/:id/logs` | Authenticated | Logs (`?service=api&tail=200&since=1h`) | + +### Instances — Health +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/instances/:id/health-check` | SUPER_ADMIN, OPERATOR | Trigger manual health check | +| GET | `/api/instances/:id/health-history` | Authenticated | Paginated health check history | + +### Instances — Backups +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/instances/:id/backup` | SUPER_ADMIN, OPERATOR | Create backup (async) | +| GET | `/api/instances/:id/backups` | Authenticated | List instance backups | + +### Backups (cross-instance) +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/backups` | Authenticated | List all backups (`?instanceId=...&page=1&limit=50`) | +| DELETE | `/api/backups/:id` | SUPER_ADMIN | Delete backup (file + record) | +| GET | `/api/backups/:id/download` | SUPER_ADMIN | Stream backup archive | + +### Health (CCP-level) +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/health` | Public | CCP API health check | +| GET | `/api/health/overview` | Authenticated | All instances with latest health | + +### Audit +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/audit` | Authenticated | Filtered, paginated audit log (`?action=...&instanceId=...&userId=...&from=...&to=...`) | + +### Settings +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/settings` | Authenticated | All settings as key-value map | +| PUT | `/api/settings/:key` | SUPER_ADMIN | Upsert a setting value | + +--- + +## Frontend Pages + +| Route | Page | Description | +|-------|------|-------------| +| `/login` | LoginPage | Email/password form | +| `/app` | DashboardPage | Stats (total, running, healthy, degraded, stopped, errors) + instance cards | +| `/app/instances` | InstanceListPage | Table view with search/filter | +| `/app/instances/new` | CreateWizardPage | 5-step wizard (info, features, email, tunnel, review) | +| `/app/instances/:id` | InstanceDetailPage | Tabs: Overview, Services, Logs, Backups, Tunnel | +| `/app/backups` | BackupsPage | Cross-instance backup list with stats | +| `/app/audit` | AuditLogPage | Filterable audit log + detail drawer | +| `/app/settings` | SettingsPage | Port ranges, Pangolin config, defaults | + +### Sidebar Navigation + +1. Dashboard (home) +2. Instances (list) +3. Backups (cross-instance) +4. Audit Log (activity trail) +5. Settings (CCP config) + +--- + +## Provisioning Flow + +When `POST /api/instances` is called, the system: + +``` +1. Validate uniqueness (slug + domain) +2. Allocate 4 ports from ranges +3. Generate 14 secrets (passwords, JWT keys, encryption keys) +4. Create Instance record (status: PROVISIONING) +5. [async] Create directory: /opt/ccp/instances/{slug}/changemaker.lite/ +6. [async] rsync CML source (excluding node_modules, .git, .env, .claude) +7. [async] Decrypt secrets → build Handlebars context +8. [async] Render 7 templates: docker-compose.yml, .env, nginx configs, Pangolin, Prometheus +9. [async] Copy static files (nginx.conf) +10. [async] docker compose pull (non-fatal if images cached) +11. [async] docker compose build +12. [async] Start infrastructure: v2-postgres + redis-changemaker +13. [async] Wait for healthy (Docker healthcheck polling) +14. [async] Start API → run prisma migrate deploy → prisma db seed +15. [async] docker compose up (all services) +16. [async] Wait for HTTP health (localhost:{api_port}/api/health) +17. [async] Set status: RUNNING +``` + +Frontend polls `GET /api/instances/:id` every 3 seconds during provisioning to show progress. + +--- + +## Health Check System + +### How It Works + +1. **Scheduler** starts on API boot (default: every 5 minutes, configurable via `HEALTH_CHECK_INTERVAL_MS`) +2. For each RUNNING instance, runs `docker compose ps --format json` +3. Parses container states and health check results +4. Determines overall status: + - **HEALTHY** — all containers running, all health checks passing + - **DEGRADED** — some containers running but not all, or some health checks failing + - **UNHEALTHY** — majority of containers down or failing health checks + - **UNKNOWN** — no containers found or compose project doesn't exist +5. Stores `HealthCheck` record with per-service status JSON, response time +6. Updates `instance.lastHealthCheck` timestamp + +### Manual Trigger + +`POST /api/instances/:id/health-check` runs an immediate check (SUPER_ADMIN/OPERATOR only). + +--- + +## Backup System + +### What Gets Backed Up + +1. **PostgreSQL dump** — `pg_dump` inside the instance's v2-postgres container +2. **Uploads archive** — tar.gz of the uploads directory (if it exists) +3. **Manifest** — JSON file with file names, sizes, SHA-256 hashes + +### Backup Flow + +``` +1. Validate instance is RUNNING +2. Create Backup record (status: PENDING) +3. [async] Set status: IN_PROGRESS +4. [async] mkdir /var/backups/ccp-instances/{slug}/{timestamp}/ +5. [async] docker compose exec v2-postgres pg_dump → v2-postgres.sql → gzip +6. [async] tar -czf uploads.tar.gz (if uploads/ exists) +7. [async] Write manifest.json with file inventory + SHA-256 hashes +8. [async] tar -czf final archive → /var/backups/ccp-instances/{slug}/backup-{slug}-{timestamp}.tar.gz +9. [async] Cleanup temp directory +10. [async] Update Backup record (COMPLETED, archivePath, sizeBytes, manifest) +11. [async] Write audit log +``` + +### Retention + +`cleanupOldBackups(retentionDays)` deletes archives + records older than the configured retention period (default: 30 days). + +--- + +## Phased Implementation + +### Phase 1: Foundation (COMPLETE) +**Goal:** Skeleton that boots — database, auth, project structure. + +**Delivered:** +- Prisma schema with all 10 models (User, Instance, HealthCheck, Backup, AuditLog, etc.) +- JWT authentication (access + refresh tokens with atomic rotation) +- Role-based access control (SUPER_ADMIN, OPERATOR, VIEWER) +- Zod-validated environment configuration +- AES-256-GCM encryption for instance secrets +- React admin shell with dark theme, sidebar navigation, protected routes +- Zustand auth store with token persistence + refresh interceptor +- Login page, Dashboard placeholder, Settings page +- Docker Compose orchestration (PostgreSQL, Redis, API, Admin) +- Placeholder pages wired for Audit Log, Backups + +### Phase 2: Docker Lifecycle (COMPLETE) +**Goal:** Create and manage running CML instances. + +**Delivered:** +- Instance CRUD with slug/domain uniqueness validation +- Port allocation across 4 ranges (API, Admin, PostgreSQL, Nginx) +- Secret generation (14 secrets: postgres, redis, JWT, encryption, admin passwords) +- Handlebars template engine rendering 7 config files per instance +- 13-step async provisioning (copy source → render config → pull → build → migrate → seed → start) +- Lifecycle operations: start, stop, restart (whole stack or individual service) +- Container status via `docker compose ps --format json` +- Log viewing via `docker compose logs` with service/tail/since filters +- 5-step Create Instance wizard (basic info → features → email → tunnel → review) +- Instance detail page with tabs (Overview, Services, Logs, Backups, Tunnel) +- Service health grid with per-container restart/log-view actions +- Provisioning progress indicator (polls every 3s) +- Audit logging on all lifecycle operations (7 action types) + +### Phase 3: Observability + Backups (COMPLETE) +**Goal:** Visibility into instance health, operator activity trail, and data protection. + +**Delivered:** + +#### Part A — Audit Log +- Audit service with filtered queries (action, instance, user, date range) + pagination +- `GET /api/audit` endpoint with query parameter filtering +- IP address capture on all audit log entries (existing + new) +- `USER_LOGIN` audit event on successful authentication +- `SETTINGS_UPDATE` audit event on settings changes +- Full AuditLogPage: filterable table, action-colored tags, detail drawer with JSON inspector, server-side pagination, 30s auto-refresh toggle + +#### Part B — Health Checks +- Health service with `checkInstanceHealth()` analyzing Docker container states +- Overall status determination (HEALTHY/DEGRADED/UNHEALTHY/UNKNOWN) from per-container state +- Scheduled health checker (default 5-minute interval, configurable, 0 to disable) +- `POST /api/instances/:id/health-check` for manual checks +- `GET /api/instances/:id/health-history` for paginated history +- Health card in Instance Detail overview with "Check Now" button + history table +- Dashboard stat cards: Healthy and Degraded instance counts from `/api/health/overview` + +#### Part C — Backups +- Backup service: pg_dump via Docker exec, uploads tar.gz, SHA-256 manifest, final archive +- Async backup creation with PENDING → IN_PROGRESS → COMPLETED/FAILED progression +- `POST /api/instances/:id/backup` — create backup +- `GET /api/instances/:id/backups` — instance-scoped backup list +- `GET /api/backups` — cross-instance backup list with pagination +- `DELETE /api/backups/:id` — delete backup (file + DB record) +- `GET /api/backups/:id/download` — stream backup archive for download +- BackupsPage: cross-instance table, instance filter, stats (count/size/last), "Backup All Running" +- Enhanced Instance Detail backups tab: create/download/delete actions, status polling +- Backup storage volume mount in docker-compose.yml +- Old backup cleanup utility (configurable retention days) + +### Phase 4: Pangolin Integration (PLANNED) +**Goal:** Automated tunnel setup for exposing instances to the internet. + +**Scope:** +- Pangolin API client (site creation, resource management, Newt credentials) +- Automated tunnel setup endpoint (`POST /api/instances/:id/setup-tunnel`) +- Per-instance Newt container management (start/stop with stored credentials) +- Resource sync for all instance subdomains (app, api, media, docs, etc.) +- Tunnel status monitoring in Instance Detail tunnel tab +- Bulk tunnel setup for fleet-wide deployment + +### Phase 5: Upgrades + Git Integration (PLANNED) +**Goal:** Rolling upgrades and version management. + +**Scope:** +- Git pull + branch checkout for instance source code +- Database migration execution (prisma migrate deploy) +- Docker image rebuild + rolling restart +- Upgrade progress tracking (similar to provisioning) +- Rollback capability (pre-upgrade backup + restore) +- Version display on instance cards and detail pages +- `INSTANCE_UPGRADE` audit event + +### Phase 6: User Management + RBAC (PLANNED) +**Goal:** Multi-operator support with granular permissions. + +**Scope:** +- User CRUD pages (create, edit, delete operators) +- Role assignment and management +- Per-instance access control (operator can only manage assigned instances) +- Invitation flow (email invite with temporary password) +- Password change and reset functionality +- Session management (view/revoke active sessions) + +### Phase 7: Monitoring Dashboard (PLANNED) +**Goal:** Fleet-wide observability with trends and alerting. + +**Scope:** +- Health trend charts (uptime over time) +- Resource utilization (CPU, memory, disk via Docker stats) +- Alert rules (instance down > N minutes, disk usage > threshold) +- Notification channels (email, webhook) +- Backup compliance monitoring (last backup age alerts) +- Fleet summary dashboard with at-a-glance status + +### Phase 8: Instance Configuration UI (PLANNED) +**Goal:** Edit instance configuration without SSH. + +**Scope:** +- Feature flag toggles (media, listmonk, gancio, monitoring) +- SMTP configuration management +- .env variable editor (safe subset) +- Docker Compose service scaling +- Configuration diff preview before applying +- Auto-restart on config change + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NODE_ENV` | `development` | Environment mode | +| `PORT` | `5000` | API server port | +| `DATABASE_URL` | — | PostgreSQL connection string | +| `REDIS_URL` | `redis://localhost:6399` | Redis connection string | +| `JWT_ACCESS_SECRET` | — | JWT signing key (min 32 chars) | +| `JWT_REFRESH_SECRET` | — | Refresh token signing key (min 32 chars) | +| `JWT_ACCESS_EXPIRES_IN` | `15m` | Access token lifetime | +| `JWT_REFRESH_EXPIRES_IN` | `7d` | Refresh token lifetime | +| `ENCRYPTION_KEY` | — | AES-256 key for secrets at rest (min 32 hex chars) | +| `INITIAL_ADMIN_EMAIL` | `admin@example.com` | Bootstrap admin email | +| `INITIAL_ADMIN_PASSWORD` | `ChangeMe2025!!` | Bootstrap admin password (min 12 chars) | +| `CORS_ORIGINS` | `http://localhost:5100` | Allowed origins (comma-separated) | +| `INSTANCES_BASE_PATH` | `/opt/ccp/instances` | Where instance directories live | +| `CML_SOURCE_PATH` | `/home/bunker-admin/changemaker.lite` | CML source to clone from | +| `CML_GIT_REPO` | — | Git repo URL (if cloning remotely) | +| `CML_GIT_BRANCH` | `v2` | Default branch for new instances | +| `PORT_RANGE_API_START` | `14000` | API port range start | +| `PORT_RANGE_API_END` | `14999` | API port range end | +| `PORT_RANGE_ADMIN_START` | `13000` | Admin port range start | +| `PORT_RANGE_ADMIN_END` | `13999` | Admin port range end | +| `PORT_RANGE_POSTGRES_START` | `15400` | PostgreSQL port range start | +| `PORT_RANGE_POSTGRES_END` | `15499` | PostgreSQL port range end | +| `PORT_RANGE_NGINX_START` | `10000` | Nginx port range start | +| `PORT_RANGE_NGINX_END` | `10999` | Nginx port range end | +| `PANGOLIN_API_URL` | — | Pangolin API base URL | +| `PANGOLIN_API_KEY` | — | Pangolin API key | +| `PANGOLIN_ORG_ID` | — | Pangolin organization ID | +| `HEALTH_CHECK_INTERVAL_MS` | `300000` | Health check interval (0 to disable) | +| `BACKUP_STORAGE_PATH` | `/var/backups/ccp-instances` | Backup archive storage directory | +| `BACKUP_RETENTION_DAYS` | `30` | Auto-cleanup threshold | + +--- + +## Docker Services + +```yaml +services: + ccp-postgres: # PostgreSQL 16 Alpine — CCP database (port 5480) + ccp-redis: # Redis 7 Alpine — rate limiting, caching (port 6399) + ccp-api: # Node 20 + Docker CLI — API server (port 5000) + ccp-admin: # Nginx + React SPA — admin GUI (port 5100) +``` + +### Volume Mounts (ccp-api) + +| Host | Container | Mode | Purpose | +|------|-----------|------|---------| +| `./api` | `/app` | rw | Source code (dev) | +| `./templates` | `/app/templates` | ro | Handlebars templates | +| `/var/run/docker.sock` | `/var/run/docker.sock` | — | Docker CLI access | +| `$INSTANCES_BASE_PATH` | Same | rw | Instance directories | +| `$CML_SOURCE_PATH` | Same | ro | CML source for provisioning | +| `$BACKUP_STORAGE_PATH` | Same | rw | Backup archives | + +--- + +## File Inventory + +### API (`api/`) +``` +src/ +├── server.ts # Express app + route mounting + health scheduler start +├── config/ +│ ├── env.ts # Zod env validation (30+ vars) +│ └── redis.ts # Redis client config +├── middleware/ +│ ├── auth.ts # authenticate + requireRole middleware +│ ├── error-handler.ts # AppError class + global error handler +│ └── validate.ts # Zod request body validation +├── modules/ +│ ├── auth/ +│ │ ├── auth.routes.ts # POST login/refresh/logout, GET /me +│ │ ├── auth.schemas.ts # Zod schemas for auth payloads +│ │ └── auth.service.ts # JWT generation, bcrypt verify, token rotation +│ ├── instances/ +│ │ ├── instances.routes.ts # CRUD + lifecycle + services + logs + health + backups +│ │ ├── instances.schemas.ts # Zod create/update validation +│ │ ├── instances.service.ts # Business logic + audit logging with IP capture +│ │ └── provisioner.ts # 13-step async provisioning orchestration +│ ├── audit/ +│ │ ├── audit.routes.ts # GET /api/audit with filters + pagination +│ │ └── audit.service.ts # Prisma query builder for audit logs +│ ├── backups/ +│ │ └── backup.routes.ts # GET list, DELETE, GET download +│ ├── health/ +│ │ └── health.routes.ts # Public health + authenticated overview +│ └── settings/ +│ └── settings.routes.ts # GET all, PUT :key (+ audit logging) +├── services/ +│ ├── docker.service.ts # Docker CLI wrapper (ps, exec, logs, up, down, etc.) +│ ├── health.service.ts # Health check logic + 5-minute scheduler +│ ├── backup.service.ts # pg_dump + tar + manifest + cleanup +│ ├── port-allocator.ts # Port range management +│ ├── secret-generator.ts # CML-compatible credential generation +│ └── template-engine.ts # Handlebars rendering for 7 config files +└── utils/ + ├── encryption.ts # AES-256-GCM encrypt/decrypt + └── logger.ts # Winston config +``` + +### Admin (`admin/`) +``` +src/ +├── App.tsx # Route definitions + dark theme config +├── main.tsx # React entry +├── components/ +│ ├── AppLayout.tsx # Sidebar nav + header + user dropdown +│ ├── InstanceCard.tsx # Card with status, health bar, features +│ ├── ServiceHealthGrid.tsx # Container state table with actions +│ ├── LogViewer.tsx # Log display with service filter +│ └── ProtectedRoute.tsx # Auth guard wrapper +├── pages/ +│ ├── LoginPage.tsx # Auth form +│ ├── DashboardPage.tsx # Stats + health overview + instance cards +│ ├── InstanceListPage.tsx # Instance table +│ ├── CreateWizardPage.tsx # 5-step provisioning wizard +│ ├── InstanceDetailPage.tsx # Tabbed detail (overview/services/logs/backups/tunnel) +│ ├── BackupsPage.tsx # Cross-instance backup manager +│ ├── AuditLogPage.tsx # Filterable audit trail +│ └── SettingsPage.tsx # CCP configuration +├── stores/ +│ └── auth.store.ts # Zustand auth + localStorage persistence +├── lib/ +│ └── api.ts # Axios + auth interceptors + token refresh +└── types/ + └── api.ts # TypeScript interfaces for all API models +``` + +### Templates (`templates/`) +``` +docker-compose.yml.hbs # Full CML docker-compose with ports/secrets/features +env.hbs # Instance .env file +nginx/ +├── nginx.conf # Global nginx config (static copy) +└── conf.d/ + ├── default.conf.hbs # Subdomain routing + ├── api.conf.hbs # API reverse proxy + └── services.conf.hbs # Service proxies +configs/ +├── pangolin/resources.yml.hbs # Tunnel resource definitions +└── prometheus/prometheus.yml.hbs # Monitoring scrape targets +``` + +--- + +## Quick Start + +```bash +# 1. Clone and enter the CCP directory +cd changemaker.lite/changemaker-control-panel + +# 2. Copy environment file +cp .env.example .env +# Edit .env: set strong passwords for JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY + +# 3. Start CCP +docker compose up -d + +# 4. Run migrations + seed +docker compose exec ccp-api npx prisma migrate deploy +docker compose exec ccp-api npx prisma db seed + +# 5. Access admin GUI +open http://localhost:5100 +# Login with INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD from .env +``` + +--- + +## Development + +```bash +# API development (hot reload) +cd api && npm install && npx tsx src/server.ts + +# Admin development (Vite dev server) +cd admin && npm install && npm run dev + +# Type checking +cd api && npx tsc --noEmit +cd admin && npx tsc --noEmit + +# Database operations +cd api && npx prisma migrate dev # Create/apply migrations +cd api && npx prisma studio # Browse database GUI +cd api && npx prisma db seed # Re-seed admin user +``` diff --git a/changemaker-control-panel/Dockerfile.admin b/changemaker-control-panel/Dockerfile.admin new file mode 100644 index 00000000..88ae7325 --- /dev/null +++ b/changemaker-control-panel/Dockerfile.admin @@ -0,0 +1,25 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY admin/package.json admin/package-lock.json ./ +RUN npm ci + +COPY admin/ . +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html + +# SPA fallback +RUN echo 'server { \ + listen 5100; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { try_files $uri $uri/ /index.html; } \ + location /api/ { proxy_pass http://ccp-api:5000; } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 5100 diff --git a/changemaker-control-panel/Dockerfile.api b/changemaker-control-panel/Dockerfile.api new file mode 100644 index 00000000..a653bce7 --- /dev/null +++ b/changemaker-control-panel/Dockerfile.api @@ -0,0 +1,20 @@ +FROM node:20-alpine + +# Install Docker CLI (needed to manage instance containers) + rsync (for provisioning) +RUN apk add --no-cache docker-cli docker-cli-compose rsync + +WORKDIR /app + +# Install dependencies +COPY api/package.json api/package-lock.json ./ +RUN npm ci + +# Copy source +COPY api/ . + +# Generate Prisma client +RUN npx prisma generate + +EXPOSE 5000 + +CMD ["npx", "tsx", "src/server.ts"] diff --git a/changemaker-control-panel/admin/index.html b/changemaker-control-panel/admin/index.html new file mode 100644 index 00000000..e1b0e651 --- /dev/null +++ b/changemaker-control-panel/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + Changemaker Control Panel + + +
+ + + diff --git a/changemaker-control-panel/admin/package-lock.json b/changemaker-control-panel/admin/package-lock.json new file mode 100644 index 00000000..5584b81c --- /dev/null +++ b/changemaker-control-panel/admin/package-lock.json @@ -0,0 +1,3012 @@ +{ + "name": "ccp-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ccp-admin", + "version": "1.0.0", + "dependencies": { + "@ant-design/icons": "^5.6.0", + "@ant-design/v5-patch-for-react-19": "^1.0.3", + "antd": "^5.23.0", + "axios": "^1.7.9", + "dayjs": "^1.11.19", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@ant-design/v5-patch-for-react-19": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@ant-design/v5-patch-for-react-19/-/v5-patch-for-react-19-1.0.3.tgz", + "integrity": "sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w==", + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "antd": ">=5.22.6", + "react": ">=19.0.0", + "react-dom": ">=19.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "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==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/changemaker-control-panel/admin/package.json b/changemaker-control-panel/admin/package.json new file mode 100644 index 00000000..513f1215 --- /dev/null +++ b/changemaker-control-panel/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "ccp-admin", + "version": "1.0.0", + "description": "Changemaker Control Panel — Admin GUI", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ant-design/icons": "^5.6.0", + "@ant-design/v5-patch-for-react-19": "^1.0.3", + "antd": "^5.23.0", + "axios": "^1.7.9", + "dayjs": "^1.11.19", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/changemaker-control-panel/admin/src/App.tsx b/changemaker-control-panel/admin/src/App.tsx new file mode 100644 index 00000000..a66e2cf6 --- /dev/null +++ b/changemaker-control-panel/admin/src/App.tsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { App as AntApp, ConfigProvider, theme, Spin } from 'antd'; +import { useAuthStore } from '@/stores/auth.store'; +import ProtectedRoute from '@/components/ProtectedRoute'; +import AppLayout from '@/components/AppLayout'; +import LoginPage from '@/pages/LoginPage'; +import DashboardPage from '@/pages/DashboardPage'; +import InstanceListPage from '@/pages/InstanceListPage'; +import CreateWizardPage from '@/pages/CreateWizardPage'; +import InstanceDetailPage from '@/pages/InstanceDetailPage'; +import RegisterInstancePage from '@/pages/RegisterInstancePage'; +import BackupsPage from '@/pages/BackupsPage'; +import AuditLogPage from '@/pages/AuditLogPage'; +import SettingsPage from '@/pages/SettingsPage'; + +export default function App() { + const { hydrate, isLoading } = useAuthStore(); + + useEffect(() => { + hydrate(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + + + } /> + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + + + ); +} diff --git a/changemaker-control-panel/admin/src/components/AppLayout.tsx b/changemaker-control-panel/admin/src/components/AppLayout.tsx new file mode 100644 index 00000000..c5e6ae15 --- /dev/null +++ b/changemaker-control-panel/admin/src/components/AppLayout.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react'; +import { Layout, Menu, Button, Typography, Avatar, Dropdown, Grid, Drawer } from 'antd'; +import { + DashboardOutlined, + CloudServerOutlined, + SaveOutlined, + AuditOutlined, + SettingOutlined, + LogoutOutlined, + UserOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MenuOutlined, +} from '@ant-design/icons'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '@/stores/auth.store'; + +const { Header, Sider, Content } = Layout; + +export default function AppLayout() { + const [collapsed, setCollapsed] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuthStore(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + + const menuItems = [ + { + key: '/app', + icon: , + label: 'Dashboard', + }, + { + key: '/app/instances', + icon: , + label: 'Instances', + }, + { + key: '/app/backups', + icon: , + label: 'Backups', + }, + { + key: '/app/audit', + icon: , + label: 'Audit Log', + }, + { + key: '/app/settings', + icon: , + label: 'Settings', + }, + ]; + + // Use startsWith matching with longest-match preference so sub-routes + // like /app/instances/123 highlight the "Instances" menu item, not Dashboard. + const selectedKey = menuItems + .filter((item) => location.pathname === item.key || location.pathname.startsWith(item.key + '/')) + .sort((a, b) => b.key.length - a.key.length)[0]?.key || '/app'; + + const handleMenuClick = ({ key }: { key: string }) => { + navigate(key); + if (isMobile) setDrawerOpen(false); + }; + + const userMenu = { + items: [ + { + key: 'logout', + icon: , + label: 'Logout', + onClick: async () => { + await logout(); + navigate('/login'); + }, + }, + ], + }; + + const siderContent = ( + <> +
+ + {!isMobile && collapsed ? 'CCP' : 'Control Panel'} + +
+ + + ); + + return ( + + {isMobile ? ( + setDrawerOpen(false)} + width={240} + styles={{ body: { padding: 0, background: '#001529' } }} + closable={false} + > + {siderContent} + + ) : ( + + {siderContent} + + )} + +
+ {isMobile ? ( +
+ + + +
+
+ ); +} diff --git a/changemaker-control-panel/admin/src/components/DiscoverInstancesDrawer.tsx b/changemaker-control-panel/admin/src/components/DiscoverInstancesDrawer.tsx new file mode 100644 index 00000000..5dcab03b --- /dev/null +++ b/changemaker-control-panel/admin/src/components/DiscoverInstancesDrawer.tsx @@ -0,0 +1,373 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Drawer, Table, Tag, Button, Space, Typography, Spin, Alert, Input, + Descriptions, Switch, message, Collapse, Badge, Result, +} from 'antd'; +import { + SearchOutlined, + CheckCircleOutlined, + CloudServerOutlined, + HomeOutlined, +} from '@ant-design/icons'; +import { api } from '@/lib/api'; +import type { DiscoveredInstance, DiscoveryResult, ImportResult } from '@/types/api'; + +interface Props { + open: boolean; + onClose: () => void; + onImported: () => void; +} + +export default function DiscoverInstancesDrawer({ open, onClose, onImported }: Props) { + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState(null); + const [importResult, setImportResult] = useState(null); + const [selectedKeys, setSelectedKeys] = useState([]); + const [editedInstances, setEditedInstances] = useState>>({}); + const [error, setError] = useState(null); + const hasScanned = useRef(false); + + const runDiscovery = async () => { + setLoading(true); + setError(null); + setResult(null); + setImportResult(null); + setSelectedKeys([]); + setEditedInstances({}); + try { + const { data } = await api.post('/instances/discover'); + setResult(data.data); + // Auto-select all new instances + const newKeys = (data.data as DiscoveryResult).instances + .filter((inst) => !inst.isAlreadyRegistered) + .map((inst) => inst.basePath); + setSelectedKeys(newKeys); + } catch (err) { + const msg = (err as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error?.message || 'Discovery failed'; + setError(msg); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open && !hasScanned.current) { + runDiscovery(); + hasScanned.current = true; + } + }, [open]); + + const getEditedValue = (basePath: string, field: keyof DiscoveredInstance, original: unknown) => { + return editedInstances[basePath]?.[field] ?? original; + }; + + const setEditedField = (basePath: string, field: string, value: unknown) => { + setEditedInstances((prev) => ({ + ...prev, + [basePath]: { ...prev[basePath], [field]: value }, + })); + }; + + const handleImport = async () => { + if (!result) return; + setImporting(true); + setImportResult(null); + + const toImport = result.instances + .filter((inst) => selectedKeys.includes(inst.basePath) && !inst.isAlreadyRegistered) + .map((inst) => { + const edits = editedInstances[inst.basePath] || {}; + return { + name: (edits.name as string) || inst.name, + slug: (edits.slug as string) || inst.slug, + domain: inst.domain, + basePath: inst.basePath, + composeProject: inst.composeProject, + portConfig: inst.portConfig, + adminEmail: inst.adminEmail, + enableMedia: edits.enableMedia ?? inst.enableMedia, + enableChat: edits.enableChat ?? inst.enableChat, + enableGancio: edits.enableGancio ?? inst.enableGancio, + enableListmonk: edits.enableListmonk ?? inst.enableListmonk, + enableMonitoring: edits.enableMonitoring ?? inst.enableMonitoring, + enableDevTools: edits.enableDevTools ?? inst.enableDevTools, + enablePayments: edits.enablePayments ?? inst.enablePayments, + }; + }); + + if (toImport.length === 0) { + message.warning('No instances selected for import'); + setImporting(false); + return; + } + + try { + const { data } = await api.post('/instances/import', { instances: toImport }); + const ir = data.data as ImportResult; + setImportResult(ir); + if (ir.summary.succeeded > 0) { + message.success(`Imported ${ir.summary.succeeded} instance(s)`); + hasScanned.current = false; // Reset so next open re-scans + onImported(); + } + if (ir.summary.failed > 0) { + message.warning(`${ir.summary.failed} instance(s) failed to import`); + } + } catch (err) { + const msg = (err as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error?.message || 'Import failed'; + message.error(msg); + } finally { + setImporting(false); + } + }; + + const columns = [ + { + title: 'Name', + key: 'name', + render: (_: unknown, record: DiscoveredInstance) => ( + + + {record.isParentInstance && } color="blue">Parent} + {record.isAlreadyRegistered ? ( + {record.name} + ) : ( + setEditedField(record.basePath, 'name', e.target.value)} + style={{ width: 180 }} + /> + )} + + + {record.domain} + + + ), + }, + { + title: 'Source', + key: 'source', + width: 100, + render: (_: unknown, record: DiscoveredInstance) => ( + : } + color={record.source === 'parent' ? 'blue' : 'default'} + > + {record.source === 'parent' ? 'Parent' : 'Docker'} + + ), + }, + { + title: 'Status', + key: 'status', + width: 120, + render: (_: unknown, record: DiscoveredInstance) => ( + + + {record.isRunning ? 'Running' : 'Stopped'} + + + {record.runningContainers}/{record.totalContainers} containers + + + ), + }, + { + title: 'Tracked', + key: 'tracked', + width: 90, + render: (_: unknown, record: DiscoveredInstance) => + record.isAlreadyRegistered ? ( + Already tracked + ) : ( + New + ), + }, + ]; + + const expandedRowRender = (record: DiscoveredInstance) => { + const isEditable = !record.isAlreadyRegistered; + return ( +
+ + + {isEditable ? ( + setEditedField(record.basePath, 'slug', e.target.value)} + /> + ) : ( + {record.slug} + )} + + {record.adminEmail} + + {record.basePath} + + + {record.composeProject} + + + API:{record.portConfig.api}   Admin:{record.portConfig.admin}   + Postgres:{record.portConfig.postgres}   Nginx:{record.portConfig.nginx} + + + + {isEditable && ( + + {(['enableMedia', 'enableChat', 'enableGancio', 'enableListmonk', 'enableMonitoring', 'enableDevTools', 'enablePayments'] as const).map((flag) => ( + + setEditedField(record.basePath, flag, checked)} + /> + + {flag.replace('enable', '')} + + + ))} + + ), + }]} + /> + )} +
+ ); + }; + + const selectableInstances = result?.instances.filter((inst) => !inst.isAlreadyRegistered) || []; + const selectedCount = selectedKeys.filter((key) => + selectableInstances.some((inst) => inst.basePath === key) + ).length; + + return ( + Done + ) : ( + + + + + ) + } + > + {loading && ( +
+ + + Scanning Docker projects and parsing .env files... + +
+ )} + + {error && } + + {importResult && ( +
+ + {importResult.results.filter((r) => !r.success).map((r) => ( + + ))} + {importResult.results.filter((r) => r.success).map((r) => ( + } + style={{ marginBottom: 4 }} + showIcon + /> + ))} +
+ )} + + {!loading && result && !importResult && ( + <> + + + Found {result.summary.total} instance(s) + + + New + + + Already tracked + + {result.summary.parentFound && } color="blue">Parent found} + + } + style={{ marginBottom: 16 }} + /> + + {result.instances.length === 0 ? ( + } + title="No CML instances found" + subTitle="No running Docker Compose projects with the CML fingerprint were detected." + /> + ) : ( + setSelectedKeys(keys as string[]), + getCheckboxProps: (record) => ({ + disabled: record.isAlreadyRegistered, + }), + }} + rowClassName={(record) => record.isAlreadyRegistered ? 'ant-table-row-disabled' : ''} + /> + )} + + )} + + ); +} diff --git a/changemaker-control-panel/admin/src/components/InstanceCard.tsx b/changemaker-control-panel/admin/src/components/InstanceCard.tsx new file mode 100644 index 00000000..a6924809 --- /dev/null +++ b/changemaker-control-panel/admin/src/components/InstanceCard.tsx @@ -0,0 +1,127 @@ +import { Card, Tag, Typography, Space, Button, Progress, Tooltip } from 'antd'; +import { + PlayCircleOutlined, + PauseCircleOutlined, + LinkOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import type { Instance } from '@/types/api'; + +const statusColors: Record = { + RUNNING: 'green', + STOPPED: 'default', + PROVISIONING: 'processing', + ERROR: 'red', + DESTROYING: 'orange', +}; + +interface InstanceCardProps { + instance: Instance; + onStart?: (id: string) => void; + onStop?: (id: string) => void; +} + +export default function InstanceCard({ instance, onStart, onStop }: InstanceCardProps) { + const navigate = useNavigate(); + + const healthCheck = instance.healthChecks?.[0]; + const healthPercent = healthCheck + ? Math.round((healthCheck.healthyServices / healthCheck.totalServices) * 100) + : 0; + + return ( + navigate(`/app/instances/${instance.id}`)} + style={{ width: '100%' }} + actions={[ + +
+ ); +} diff --git a/changemaker-control-panel/admin/src/lib/api.ts b/changemaker-control-panel/admin/src/lib/api.ts new file mode 100644 index 00000000..a731b987 --- /dev/null +++ b/changemaker-control-panel/admin/src/lib/api.ts @@ -0,0 +1,92 @@ +import axios from 'axios'; + +interface AuthResponse { + user: { id: string; email: string; name: string; role: string }; + accessToken?: string; + refreshToken?: string; +} + +export const api = axios.create({ + baseURL: '/api', +}); + +// Token accessor — set by auth store on init +let getTokens: () => { accessToken: string | null; refreshToken: string | null } = () => ({ + accessToken: null, + refreshToken: null, +}); +let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {}; +let onAuthFailure: () => void = () => {}; + +export function registerAuthCallbacks(callbacks: { + getTokens: typeof getTokens; + onTokenRefresh: typeof onTokenRefresh; + onAuthFailure: typeof onAuthFailure; +}) { + getTokens = callbacks.getTokens; + onTokenRefresh = callbacks.onTokenRefresh; + onAuthFailure = callbacks.onAuthFailure; +} + +// Request interceptor: attach access token +api.interceptors.request.use((config) => { + const { accessToken } = getTokens(); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// Response interceptor: handle 401 with token refresh +let refreshPromise: Promise | null = null; + +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + const errorCode = error.response?.data?.error?.code; + + // Skip retry logic for the refresh endpoint itself to avoid circular await deadlock + const isRefreshRequest = originalRequest.url?.includes('/auth/refresh'); + + if ( + error.response?.status === 401 && + (errorCode === 'INVALID_TOKEN' || errorCode === 'AUTH_REQUIRED') && + !originalRequest._retry && + !isRefreshRequest + ) { + originalRequest._retry = true; + + const { refreshToken } = getTokens(); + if (!refreshToken) { + onAuthFailure(); + return Promise.reject(error); + } + + try { + if (!refreshPromise) { + refreshPromise = api + .post('/auth/refresh', { refreshToken }) + .then((res) => res.data) + .finally(() => { + refreshPromise = null; + }); + } + + const data = await refreshPromise; + if (!data.accessToken || !data.refreshToken) { + onAuthFailure(); + return Promise.reject(new Error('Missing tokens in refresh response')); + } + onTokenRefresh(data.accessToken, data.refreshToken); + originalRequest.headers.Authorization = `Bearer ${data.accessToken}`; + return api(originalRequest); + } catch { + onAuthFailure(); + return Promise.reject(error); + } + } + + return Promise.reject(error); + } +); diff --git a/changemaker-control-panel/admin/src/main.tsx b/changemaker-control-panel/admin/src/main.tsx new file mode 100644 index 00000000..9707d827 --- /dev/null +++ b/changemaker-control-panel/admin/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/changemaker-control-panel/admin/src/pages/AuditLogPage.tsx b/changemaker-control-panel/admin/src/pages/AuditLogPage.tsx new file mode 100644 index 00000000..29aa8d99 --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/AuditLogPage.tsx @@ -0,0 +1,271 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { + Typography, + Table, + Tag, + Space, + Select, + DatePicker, + Button, + Drawer, + Switch, + message, +} from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { api } from '@/lib/api'; +import type { AuditLogEntry, Instance } from '@/types/api'; + +const { RangePicker } = DatePicker; + +const AUDIT_ACTIONS = [ + 'INSTANCE_CREATE', + 'INSTANCE_UPDATE', + 'INSTANCE_DELETE', + 'INSTANCE_START', + 'INSTANCE_STOP', + 'INSTANCE_RESTART', + 'INSTANCE_UPGRADE', + 'BACKUP_CREATE', + 'BACKUP_DELETE', + 'PANGOLIN_SETUP', + 'PANGOLIN_SYNC', + 'USER_LOGIN', + 'USER_CREATE', + 'USER_UPDATE', + 'USER_DELETE', + 'SETTINGS_UPDATE', +]; + +const actionColors: Record = { + INSTANCE_CREATE: 'green', + INSTANCE_START: 'green', + INSTANCE_UPDATE: 'blue', + INSTANCE_RESTART: 'blue', + INSTANCE_STOP: 'orange', + INSTANCE_DELETE: 'red', + INSTANCE_UPGRADE: 'cyan', + BACKUP_CREATE: 'purple', + BACKUP_DELETE: 'red', + PANGOLIN_SETUP: 'geekblue', + PANGOLIN_SYNC: 'geekblue', + USER_LOGIN: 'default', + USER_CREATE: 'green', + USER_UPDATE: 'blue', + USER_DELETE: 'red', + SETTINGS_UPDATE: 'volcano', +}; + +export default function AuditLogPage() { + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [actionFilter, setActionFilter] = useState(); + const [instanceFilter, setInstanceFilter] = useState(); + const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null); + const [instances, setInstances] = useState([]); + const [selectedLog, setSelectedLog] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(false); + const intervalRef = useRef>(undefined); + + const fetchLogs = useCallback(async () => { + try { + const params: Record = { + page: String(page), + limit: String(pageSize), + }; + if (actionFilter) params.action = actionFilter; + if (instanceFilter) params.instanceId = instanceFilter; + if (dateRange?.[0]) params.from = dateRange[0].startOf('day').toISOString(); + if (dateRange?.[1]) params.to = dateRange[1].endOf('day').toISOString(); + + const { data } = await api.get('/audit', { params }); + setLogs(data.data); + setTotal(data.total); + } catch { + message.error('Failed to load audit logs'); + } finally { + setLoading(false); + } + }, [page, pageSize, actionFilter, instanceFilter, dateRange]); + + const fetchInstances = useCallback(async () => { + try { + const { data } = await api.get('/instances'); + setInstances(data.data); + } catch { + // Silently fail + } + }, []); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + useEffect(() => { + setLoading(true); + fetchLogs(); + }, [fetchLogs]); + + // Auto-refresh + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(fetchLogs, 30_000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh, fetchLogs]); + + const columns = [ + { + title: 'Timestamp', + dataIndex: 'createdAt', + width: 180, + render: (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: 'Action', + dataIndex: 'action', + width: 160, + render: (action: string) => ( + {action} + ), + }, + { + title: 'Instance', + dataIndex: 'instance', + width: 160, + render: (inst: AuditLogEntry['instance']) => inst?.name || '-', + }, + { + title: 'User', + dataIndex: 'user', + width: 200, + render: (user: AuditLogEntry['user']) => user?.email || '-', + }, + { + title: 'IP Address', + dataIndex: 'ipAddress', + width: 140, + render: (ip: string | undefined) => ip || '-', + }, + ]; + + return ( +
+
+ + Audit Log + + + Auto-refresh + + + +
+ + + { setInstanceFilter(v); setPage(1); }} + options={instances.map((i) => ({ label: i.name, value: i.id }))} + /> + { + setDateRange(vals as [dayjs.Dayjs, dayjs.Dayjs] | null); + setPage(1); + }} + /> + + +
({ + onClick: () => setSelectedLog(record), + style: { cursor: 'pointer' }, + })} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: ['25', '50', '100'], + onChange: (p, ps) => { setPage(p); setPageSize(ps); }, + showTotal: (t) => `${t} entries`, + }} + size="small" + /> + + setSelectedLog(null)} + width={520} + > + {selectedLog && ( + +
+ Action +
+ + {selectedLog.action} + +
+
+
+ Timestamp +
{dayjs(selectedLog.createdAt).format('YYYY-MM-DD HH:mm:ss')}
+
+
+ User +
{selectedLog.user?.email || selectedLog.userId || '-'}
+
+
+ Instance +
{selectedLog.instance?.name || selectedLog.instanceId || '-'}
+
+
+ IP Address +
{selectedLog.ipAddress || '-'}
+
+
+ Details +
+                {JSON.stringify(selectedLog.details, null, 2) || 'null'}
+              
+
+
+ )} +
+ + ); +} diff --git a/changemaker-control-panel/admin/src/pages/BackupsPage.tsx b/changemaker-control-panel/admin/src/pages/BackupsPage.tsx new file mode 100644 index 00000000..9c8a3b6d --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/BackupsPage.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Typography, + Table, + Tag, + Space, + Select, + Button, + Popconfirm, + Statistic, + Card, + Row, + Col, + message, +} from 'antd'; +import { + ReloadOutlined, + CloudDownloadOutlined, + DeleteOutlined, + CloudUploadOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { api } from '@/lib/api'; +import type { Instance } from '@/types/api'; + +interface BackupRow { + id: string; + instanceId: string; + status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; + archivePath?: string; + sizeBytes?: string | number | null; + manifest?: Record; + startedAt: string; + completedAt?: string; + errorMessage?: string; + s3Uploaded: boolean; + instance?: { id: string; name: string; slug: string } | null; +} + +function formatSize(bytes: string | number | null | undefined): string { + if (bytes == null) return '-'; + const n = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +export default function BackupsPage() { + const [backups, setBackups] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [instanceFilter, setInstanceFilter] = useState(); + const [instances, setInstances] = useState([]); + const [backingUpAll, setBackingUpAll] = useState(false); + + const fetchBackups = useCallback(async () => { + try { + const params: Record = { page: String(page), limit: String(pageSize) }; + if (instanceFilter) params.instanceId = instanceFilter; + const { data } = await api.get('/backups', { params }); + setBackups(data.data); + setTotal(data.total); + } catch { + message.error('Failed to load backups'); + } finally { + setLoading(false); + } + }, [page, pageSize, instanceFilter]); + + const fetchInstances = useCallback(async () => { + try { + const { data } = await api.get('/instances'); + setInstances(data.data); + } catch { + // Silently fail + } + }, []); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + useEffect(() => { + setLoading(true); + fetchBackups(); + }, [fetchBackups]); + + const handleDelete = async (id: string) => { + try { + await api.delete(`/backups/${id}`); + message.success('Backup deleted'); + fetchBackups(); + } catch { + message.error('Failed to delete backup'); + } + }; + + const handleDownload = (id: string) => { + window.open(`/api/backups/${id}/download`, '_blank'); + }; + + const handleBackupAll = async () => { + const running = instances.filter((i) => i.status === 'RUNNING'); + if (running.length === 0) { + message.warning('No running instances to backup'); + return; + } + setBackingUpAll(true); + try { + await Promise.all(running.map((inst) => api.post(`/instances/${inst.id}/backup`))); + message.success(`Backup started for ${running.length} instance(s)`); + fetchBackups(); + } catch { + message.error('Some backups failed to start'); + } finally { + setBackingUpAll(false); + } + }; + + // Summary stats + const totalSize = backups + .filter((b) => b.status === 'COMPLETED' && b.sizeBytes) + .reduce((sum, b) => sum + (typeof b.sizeBytes === 'string' ? parseInt(b.sizeBytes, 10) : (b.sizeBytes || 0)), 0); + const lastBackup = backups.find((b) => b.status === 'COMPLETED'); + + const statusColors: Record = { + COMPLETED: 'green', + FAILED: 'red', + IN_PROGRESS: 'processing', + PENDING: 'default', + }; + + return ( +
+
+ + Backups + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
{ setPage(p); setPageSize(ps); }, + showTotal: (t) => `${t} backups`, + }} + columns={[ + { + title: 'Instance', + dataIndex: 'instance', + width: 160, + render: (inst: BackupRow['instance']) => inst?.name || '-', + }, + { + title: 'Status', + dataIndex: 'status', + width: 110, + render: (s: string) => {s}, + }, + { + title: 'Started', + dataIndex: 'startedAt', + width: 150, + render: (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm'), + }, + { + title: 'Completed', + dataIndex: 'completedAt', + width: 150, + render: (d: string | null) => (d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: 'Size', + dataIndex: 'sizeBytes', + width: 100, + render: (b: string | number | null) => formatSize(b), + }, + { + title: 'Actions', + width: 100, + render: (_: unknown, record: BackupRow) => ( + + {record.status === 'COMPLETED' && ( + , + , + , + ]} + /> + ); + } + + if (isError) { + return ( + + Retry Provisioning + , + , + , + ]} + /> + ); + } + + // Provisioning in progress + const percent = stepInfo ? Math.round((stepInfo.current / stepInfo.total) * 100) : 0; + return ( +
+ + Provisioning {data.name}... + + This may take several minutes. Please do not close this page. + + + + + {stepInfo && ( + + + Step {stepInfo.current} of {stepInfo.total} + +
+ {stepInfo.description} +
+ )} + + {!stepInfo && inst.statusMessage && ( + {inst.statusMessage} + )} + +
+ +
+
+ ); + } + + const canProceed = () => { + if (current === 0) { + return data.name && data.slug && data.domain && data.adminEmail; + } + return true; + }; + + return ( +
+ Create New Instance + + ({ title: s.title }))} style={{ marginBottom: 32 }} /> + + {steps[current].content} + +
+ + {current < steps.length - 1 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/changemaker-control-panel/admin/src/pages/DashboardPage.tsx b/changemaker-control-panel/admin/src/pages/DashboardPage.tsx new file mode 100644 index 00000000..b6fedef6 --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/DashboardPage.tsx @@ -0,0 +1,227 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { Typography, Row, Col, Button, Statistic, Card, Empty, Spin, message } from 'antd'; +import { + PlusOutlined, + CloudServerOutlined, + CheckCircleOutlined, + WarningOutlined, + HeartOutlined, + ExclamationCircleOutlined, + SyncOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api'; +import InstanceCard from '@/components/InstanceCard'; +import type { Instance } from '@/types/api'; + +interface HealthOverviewItem { + id: string; + name: string; + slug: string; + domain: string; + status: string; + lastHealthCheck?: string; + health?: { status: string; healthyServices: number; totalServices: number } | null; +} + +const AUTO_REFRESH_INTERVAL = 30_000; + +export default function DashboardPage() { + const navigate = useNavigate(); + const [instances, setInstances] = useState([]); + const [healthOverview, setHealthOverview] = useState([]); + const [loading, setLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const [secondsAgo, setSecondsAgo] = useState(0); + const lastUpdatedRef = useRef(null); + + const fetchInstances = useCallback(async () => { + try { + const { data } = await api.get('/instances'); + setInstances(data.data); + } catch { + message.error('Failed to load instances'); + } finally { + setLoading(false); + } + }, []); + + const fetchHealthOverview = useCallback(async () => { + try { + const { data } = await api.get('/health/overview'); + setHealthOverview(data.data); + } catch { + message.error('Failed to load health overview'); + } + }, []); + + const refreshAll = useCallback(async () => { + await Promise.all([fetchInstances(), fetchHealthOverview()]); + const now = new Date(); + setLastUpdated(now); + lastUpdatedRef.current = now; + setSecondsAgo(0); + }, [fetchInstances, fetchHealthOverview]); + + // Initial fetch + useEffect(() => { + refreshAll(); + }, [refreshAll]); + + // Auto-refresh every 30s + useEffect(() => { + const id = setInterval(refreshAll, AUTO_REFRESH_INTERVAL); + return () => clearInterval(id); + }, [refreshAll]); + + // Tick the "seconds ago" display every second + useEffect(() => { + const id = setInterval(() => { + if (lastUpdatedRef.current) { + setSecondsAgo(Math.floor((Date.now() - lastUpdatedRef.current.getTime()) / 1000)); + } + }, 1000); + return () => clearInterval(id); + }, []); + + const handleStart = async (id: string) => { + try { + await api.post(`/instances/${id}/start`); + message.success('Instance started'); + fetchInstances(); + } catch { + message.error('Failed to start instance'); + } + }; + + const handleStop = async (id: string) => { + try { + await api.post(`/instances/${id}/stop`); + message.success('Instance stopped'); + fetchInstances(); + } catch { + message.error('Failed to stop instance'); + } + }; + + const running = instances.filter((i) => i.status === 'RUNNING').length; + const stopped = instances.filter((i) => i.status === 'STOPPED').length; + const errors = instances.filter((i) => i.status === 'ERROR').length; + + // Health stats from overview + const healthyInstances = healthOverview.filter((h) => h.health?.status === 'HEALTHY').length; + const degradedInstances = healthOverview.filter((h) => h.health?.status === 'DEGRADED').length; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + Dashboard + + {lastUpdated && ( + + + Updated {secondsAgo < 5 ? 'just now' : `${secondsAgo}s ago`} + + )} +
+ +
+ + +
+ + } + /> + + + + + } + /> + + + + + } + /> + + + + + 0 ? { color: '#faad14' } : undefined} + prefix={degradedInstances > 0 ? : undefined} + /> + + + + + + + + + + 0 ? { color: '#ff4d4f' } : undefined} + prefix={errors > 0 ? : undefined} + /> + + + + + {instances.length === 0 ? ( + + + + ) : ( + + {instances.map((instance) => ( + + + + ))} + + )} + + ); +} diff --git a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx new file mode 100644 index 00000000..352b6284 --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx @@ -0,0 +1,1083 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { + Typography, + Tabs, + Card, + Descriptions, + Tag, + Space, + Button, + Spin, + message, + Table, + Empty, + Alert, + Popconfirm, + Progress, + Switch, + Divider, + Input, + Modal, + Form, +} from 'antd'; +import { + ArrowLeftOutlined, + LinkOutlined, + ReloadOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + SyncOutlined, + HeartOutlined, + CloudDownloadOutlined, + DeleteOutlined, + PlusOutlined, + SettingOutlined, + LockOutlined, + EyeOutlined, + EyeInvisibleOutlined, + CopyOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { useNavigate, useParams } from 'react-router-dom'; +import { api } from '@/lib/api'; +import type { Instance, HealthCheck, Backup } from '@/types/api'; +import ServiceHealthGrid, { type ServiceInfo } from '@/components/ServiceHealthGrid'; +import LogViewer from '@/components/LogViewer'; + +const statusColors: Record = { + RUNNING: 'green', + STOPPED: 'default', + PROVISIONING: 'processing', + ERROR: 'red', + DESTROYING: 'orange', +}; + +const healthColors: Record = { + HEALTHY: 'green', + DEGRADED: 'orange', + UNHEALTHY: 'red', + UNKNOWN: 'default', +}; + +export default function InstanceDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [instance, setInstance] = useState(null); + const [loading, setLoading] = useState(true); + const [services, setServices] = useState([]); + const [servicesLoading, setServicesLoading] = useState(false); + const [activeTab, setActiveTab] = useState('overview'); + const [actionLoading, setActionLoading] = useState(null); + const [selectedLogService, setSelectedLogService] = useState(undefined); + const servicesIntervalRef = useRef>(undefined); + + // Health history + const [healthHistory, setHealthHistory] = useState([]); + const [healthLoading, setHealthLoading] = useState(false); + const [checkingHealth, setCheckingHealth] = useState(false); + + // Backup state + const [backups, setBackups] = useState([]); + const [backupsLoading, setBackupsLoading] = useState(false); + const [creatingBackup, setCreatingBackup] = useState(false); + + // Feature reconfiguration state + const [featureFlags, setFeatureFlags] = useState>({}); + const [reconfiguring, setReconfiguring] = useState(false); + + // Credentials state + const [secrets, setSecrets] = useState | null>(null); + const [secretsLoading, setSecretsLoading] = useState(false); + const [secretsError, setSecretsError] = useState(null); + const [secretsFetched, setSecretsFetched] = useState(false); + const [revealAll, setRevealAll] = useState(false); + const [passwordVerified, setPasswordVerified] = useState(false); + const [passwordModalOpen, setPasswordModalOpen] = useState(false); + const [verifying, setVerifying] = useState(false); + const [passwordForm] = Form.useForm(); + + const fetchInstance = useCallback(async () => { + try { + const { data } = await api.get(`/instances/${id}`); + setInstance(data.data); + } catch { + message.error('Failed to load instance'); + navigate('/app/instances'); + } finally { + setLoading(false); + } + }, [id, navigate]); + + const fetchServices = useCallback(async () => { + if (!id) return; + setServicesLoading(true); + try { + const { data } = await api.get(`/instances/${id}/services`); + setServices(data.data); + } catch { + // Silently fail — services tab will show empty + } finally { + setServicesLoading(false); + } + }, [id]); + + const fetchHealthHistory = useCallback(async () => { + if (!id) return; + setHealthLoading(true); + try { + const { data } = await api.get(`/instances/${id}/health-history`, { params: { limit: 20 } }); + setHealthHistory(data.data); + } catch { + // Silently fail + } finally { + setHealthLoading(false); + } + }, [id]); + + const fetchBackups = useCallback(async () => { + if (!id) return; + setBackupsLoading(true); + try { + const { data } = await api.get(`/instances/${id}/backups`); + setBackups(data.data); + } catch { + // Silently fail + } finally { + setBackupsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchInstance(); + }, [fetchInstance]); + + // Auto-refresh services every 10s when on the services tab + useEffect(() => { + if (activeTab === 'services') { + fetchServices(); + servicesIntervalRef.current = setInterval(fetchServices, 10_000); + } + return () => { + if (servicesIntervalRef.current) clearInterval(servicesIntervalRef.current); + }; + }, [activeTab, fetchServices]); + + // Fetch health history when on overview tab + useEffect(() => { + if (activeTab === 'overview' && instance?.status === 'RUNNING') { + fetchHealthHistory(); + } + }, [activeTab, instance?.status, fetchHealthHistory]); + + // Fetch backups when on backups tab + useEffect(() => { + if (activeTab === 'backups') { + fetchBackups(); + } + }, [activeTab, fetchBackups]); + + // Fetch secrets (only called after password verification) + const fetchSecrets = useCallback(async () => { + if (!id) return; + setSecretsLoading(true); + setSecretsError(null); + try { + const { data } = await api.get(`/instances/${id}/secrets`); + setSecrets(data.data); + setSecretsFetched(true); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + setSecretsError(resp?.message || 'Failed to load credentials'); + } finally { + setSecretsLoading(false); + } + }, [id]); + + // Show password modal when credentials tab is first activated + useEffect(() => { + if (activeTab === 'credentials' && !passwordVerified && !secretsFetched) { + setPasswordModalOpen(true); + } + }, [activeTab, passwordVerified, secretsFetched]); + + const handlePasswordVerify = async (values: { password: string }) => { + setVerifying(true); + try { + await api.post('/auth/verify-password', { password: values.password }); + setPasswordVerified(true); + setPasswordModalOpen(false); + passwordForm.resetFields(); + fetchSecrets(); + } catch { + message.error('Incorrect password'); + passwordForm.setFields([{ name: 'password', errors: ['Incorrect password'] }]); + } finally { + setVerifying(false); + } + }; + + // Auto-refresh instance when in PROVISIONING state + useEffect(() => { + if (instance?.status === 'PROVISIONING') { + const interval = setInterval(fetchInstance, 3_000); + return () => clearInterval(interval); + } + }, [instance?.status, fetchInstance]); + + const handleAction = async (action: string, label: string) => { + setActionLoading(action); + try { + await api.post(`/instances/${id}/${action}`); + message.success(`${label} successful`); + fetchInstance(); + if (activeTab === 'services') fetchServices(); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || `${label} failed`); + } finally { + setActionLoading(null); + } + }; + + const handleServiceRestart = async (service: string) => { + try { + await api.post(`/instances/${id}/restart?service=${service}`); + message.success(`${service} restarted`); + fetchServices(); + } catch { + message.error(`Failed to restart ${service}`); + } + }; + + const handleViewServiceLogs = (service: string) => { + setSelectedLogService(service); + setActiveTab('logs'); + }; + + const handleHealthCheck = async () => { + setCheckingHealth(true); + try { + await api.post(`/instances/${id}/health-check`); + message.success('Health check complete'); + fetchHealthHistory(); + fetchInstance(); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Health check failed'); + } finally { + setCheckingHealth(false); + } + }; + + const handleCreateBackup = async () => { + setCreatingBackup(true); + try { + await api.post(`/instances/${id}/backup`); + message.success('Backup started'); + fetchBackups(); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Failed to create backup'); + } finally { + setCreatingBackup(false); + } + }; + + const handleDeleteBackup = async (backupId: string) => { + try { + await api.delete(`/backups/${backupId}`); + message.success('Backup deleted'); + fetchBackups(); + } catch { + message.error('Failed to delete backup'); + } + }; + + const handleDownloadBackup = (backupId: string) => { + window.open(`/api/backups/${backupId}/download`, '_blank'); + }; + + // Initialize feature flags when instance loads + useEffect(() => { + if (instance) { + setFeatureFlags({ + enableMedia: instance.enableMedia, + enableListmonk: instance.enableListmonk, + enableGancio: instance.enableGancio, + enableChat: instance.enableChat, + enableMonitoring: instance.enableMonitoring, + enableDevTools: instance.enableDevTools, + enablePayments: instance.enablePayments, + }); + } + }, [instance]); + + const handleReconfigure = async () => { + setReconfiguring(true); + try { + await api.post(`/instances/${id}/reconfigure`, featureFlags); + message.success('Reconfiguration complete'); + fetchInstance(); + if (activeTab === 'services') fetchServices(); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Reconfiguration failed'); + } finally { + setReconfiguring(false); + } + }; + + const hasFeatureChanges = instance ? ( + featureFlags.enableMedia !== instance.enableMedia || + featureFlags.enableListmonk !== instance.enableListmonk || + featureFlags.enableGancio !== instance.enableGancio || + featureFlags.enableChat !== instance.enableChat || + featureFlags.enableMonitoring !== instance.enableMonitoring || + featureFlags.enableDevTools !== instance.enableDevTools || + featureFlags.enablePayments !== instance.enablePayments + ) : false; + + if (loading || !instance) { + return ( +
+ +
+ ); + } + + const ports = instance.portConfig as Record; + const isProvisioning = instance.status === 'PROVISIONING'; + const isRegistered = instance.isRegistered; + const canStart = instance.status === 'STOPPED' || instance.status === 'ERROR'; + const canStop = instance.status === 'RUNNING' || instance.status === 'ERROR'; + const canRestart = instance.status === 'RUNNING'; + const canProvision = !isRegistered && (instance.status === 'ERROR' || instance.status === 'STOPPED'); + + const latestHealth = instance.healthChecks?.[0]; + + const overviewTab = ( + + {isProvisioning && ( + } + /> + )} + + + {instance.name} + + {instance.status} + + + {isRegistered ? ( + External — deployed outside CCP + ) : ( + Managed — provisioned by CCP + )} + + {instance.domain} + {instance.slug} + {instance.composeProject} + {instance.gitBranch} + {instance.adminEmail} + + {instance.emailTestMode ? 'Test (MailHog)' : `SMTP: ${instance.smtpHost}:${instance.smtpPort}`} + + + {instance.basePath} + + {new Date(instance.createdAt).toLocaleString()} + {new Date(instance.updatedAt).toLocaleString()} + + + + + + Media {instance.enableMedia ? 'ON' : 'OFF'} + + + Newsletter {instance.enableListmonk ? 'ON' : 'OFF'} + + + Events {instance.enableGancio ? 'ON' : 'OFF'} + + + Chat {instance.enableChat ? 'ON' : 'OFF'} + + + Monitoring {instance.enableMonitoring ? 'ON' : 'OFF'} + + + Dev Tools {instance.enableDevTools ? 'ON' : 'OFF'} + + + Payments {instance.enablePayments ? 'ON' : 'OFF'} + + + + + + + {ports.api} + {ports.admin} + {ports.postgres} + {ports.nginx} + + + + {/* Health History Section */} + {instance.status === 'RUNNING' && ( + + + Health + {latestHealth && ( + {latestHealth.status} + )} + + } + size="small" + extra={ + + } + > + {latestHealth && ( +
+ + Services: {latestHealth.healthyServices}/{latestHealth.totalServices} + + + {latestHealth.responseTimeMs != null && ( + + Response: {latestHealth.responseTimeMs}ms + + )} +
+ )} + +
dayjs(d).format('MM-DD HH:mm:ss'), + }, + { + title: 'Status', + dataIndex: 'status', + width: 100, + render: (s: string) => {s}, + }, + { + title: 'Services', + render: (_: unknown, r: HealthCheck) => `${r.healthyServices}/${r.totalServices}`, + width: 80, + }, + { + title: 'Response', + dataIndex: 'responseTimeMs', + width: 80, + render: (ms: number | null) => (ms != null ? `${ms}ms` : '-'), + }, + ]} + /> + + )} + + {instance.statusMessage && instance.status === 'ERROR' && ( + handleAction('provision', 'Retry provisioning')} + loading={actionLoading === 'provision'} + > + Retry + + ) : undefined + } + /> + )} + + ); + + const serviceNames = services.map((s) => s.service).filter(Boolean); + + const servicesTab = ( +
+
+ +
+ +
+ ); + + const logsTab = ( + + ); + + const backupsTab = ( +
+ {isRegistered && ( + + )} +
+ + {backups.length} backup{backups.length !== 1 ? 's' : ''} + + +
+ {backups.length > 0 ? ( +
( + + {s} + + ), + }, + { + title: 'Started', + dataIndex: 'startedAt', + render: (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm'), + }, + { + title: 'Completed', + dataIndex: 'completedAt', + render: (d: string | null) => (d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: 'Size', + dataIndex: 'sizeBytes', + render: (b: number | null) => (b ? `${(b / 1024 / 1024).toFixed(1)} MB` : '-'), + }, + { + title: 'Actions', + width: 120, + render: (_: unknown, record: Backup) => ( + + {record.status === 'COMPLETED' && ( + + + + + + )} + + ); + + const tunnelTab = ( + + + + {instance.pangolinSiteId || Not configured} + + + {instance.pangolinNewtId || Not configured} + + + + + ); + + const secretEntries = secrets ? Object.entries(secrets) : []; + const isRegisteredSecrets = isRegistered; + + const credentialsTab = ( + + {!passwordVerified && !secretsFetched ? ( +
+ + + Password Required + + + Re-enter your password to view instance credentials. + + +
+ ) : ( + <> + + + {secretsLoading && ( +
+ +
+ )} + + {secretsError && ( + + Retry + + } + /> + )} + + {secrets && !secretsLoading && ( + <> + {isRegisteredSecrets ? ( + + + + + {secrets.initialAdminEmail || 'Not set'} + + + + + + + } + > + + {secretEntries.map(([key, value]) => ( + + + {revealAll ? ( + + {String(value ?? 'null')} + + ) : ( + + )} + + + + + + + + ); + + return ( +
+
+ + )} + {canStop && ( + handleAction('stop', 'Stop')} + > + + + )} + {canRestart && ( + + )} + + + +
+ + , children: credentialsTab }, + { key: 'features', label: 'Features', children: featuresTab }, + { key: 'services', label: 'Services', children: servicesTab }, + { key: 'logs', label: 'Logs', children: logsTab }, + { key: 'backups', label: 'Backups', children: backupsTab }, + { key: 'tunnel', label: 'Tunnel', children: tunnelTab }, + ]} + /> +
+ ); +} diff --git a/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx b/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx new file mode 100644 index 00000000..49765865 --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx @@ -0,0 +1,283 @@ +import { useEffect, useState, useMemo } from 'react'; +import { Typography, Table, Tag, Button, Space, message, Popconfirm, Input, Select } from 'antd'; +import { + PlusOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + DeleteOutlined, + EyeOutlined, + ImportOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api'; +import type { Instance } from '@/types/api'; +import DiscoverInstancesDrawer from '@/components/DiscoverInstancesDrawer'; + +const statusColors: Record = { + RUNNING: 'green', + STOPPED: 'default', + PROVISIONING: 'processing', + ERROR: 'red', + DESTROYING: 'orange', +}; + +export default function InstanceListPage() { + const navigate = useNavigate(); + const [instances, setInstances] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [discoverOpen, setDiscoverOpen] = useState(false); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('ALL'); + + const filteredInstances = useMemo(() => { + let result = instances; + if (statusFilter !== 'ALL') { + result = result.filter((i) => i.status === statusFilter); + } + if (search.trim()) { + const q = search.toLowerCase().trim(); + result = result.filter( + (i) => + i.name.toLowerCase().includes(q) || + i.domain.toLowerCase().includes(q) || + i.slug.toLowerCase().includes(q) + ); + } + return result; + }, [instances, search, statusFilter]); + + const fetchInstances = async () => { + try { + const { data } = await api.get('/instances'); + setInstances(data.data); + } catch { + message.error('Failed to load instances'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchInstances(); + }, []); + + const handleDelete = async (id: string) => { + try { + const { data } = await api.delete(`/instances/${id}`); + message.success(data?.message || 'Instance deleted'); + fetchInstances(); + } catch { + message.error('Failed to delete instance'); + } + }; + + const handleStart = async (id: string) => { + setActionLoading(id); + try { + await api.post(`/instances/${id}/start`); + message.success('Instance started'); + fetchInstances(); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Failed to start instance'); + } finally { + setActionLoading(null); + } + }; + + const handleStop = async (id: string) => { + setActionLoading(id); + try { + await api.post(`/instances/${id}/stop`); + message.success('Instance stopped'); + fetchInstances(); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + message.error(resp?.message || 'Failed to stop instance'); + } finally { + setActionLoading(null); + } + }; + + const columns = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (name: string, record: Instance) => ( + + navigate(`/app/instances/${record.id}`)}>{name} + {record.isRegistered && External} + + ), + }, + { + title: 'Domain', + dataIndex: 'domain', + key: 'domain', + render: (domain: string) => ( + + {domain} + + ), + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: string) => {status}, + }, + { + title: 'Features', + key: 'features', + render: (_: unknown, record: Instance) => ( + + {record.enableMedia && Media} + {record.enableListmonk && Newsletter} + {record.enableGancio && Events} + {record.enableChat && Chat} + + ), + }, + { + title: 'Ports', + key: 'ports', + render: (_: unknown, record: Instance) => { + const ports = record.portConfig as Record; + return ( + + API:{ports.api} Admin:{ports.admin} + + ); + }, + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date: string) => new Date(date).toLocaleDateString(), + }, + { + title: 'Actions', + key: 'actions', + render: (_: unknown, record: Instance) => { + const isLoading = actionLoading === record.id; + const canStart = record.status === 'STOPPED' || record.status === 'ERROR'; + const canStop = record.status === 'RUNNING'; + return ( + + + + + + + + + setSearch(e.target.value)} + /> +
+ + setDiscoverOpen(false)} + onImported={fetchInstances} + /> + + ); +} diff --git a/changemaker-control-panel/admin/src/pages/LoginPage.tsx b/changemaker-control-panel/admin/src/pages/LoginPage.tsx new file mode 100644 index 00000000..e3fd3aa8 --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/LoginPage.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { Card, Form, Input, Button, Typography, Alert, Space } from 'antd'; +import { LockOutlined, MailOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores/auth.store'; + +export default function LoginPage() { + const navigate = useNavigate(); + const { login, error, isLoading } = useAuthStore(); + const [form] = Form.useForm(); + const [localError, setLocalError] = useState(null); + + const handleSubmit = async (values: { email: string; password: string }) => { + setLocalError(null); + try { + await login(values.email, values.password); + navigate('/app'); + } catch { + // Error is set in store + } + }; + + const displayError = localError || error; + + return ( +
+ + +
+ + Changemaker Control Panel + + Sign in to manage instances +
+ + {displayError && ( + setLocalError(null)} /> + )} + +
+ + } placeholder="Email" autoFocus /> + + + + } placeholder="Password" /> + + + + + + +
+
+
+ ); +} diff --git a/changemaker-control-panel/admin/src/pages/RegisterInstancePage.tsx b/changemaker-control-panel/admin/src/pages/RegisterInstancePage.tsx new file mode 100644 index 00000000..2f53c96c --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/RegisterInstancePage.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { + Typography, + Form, + Input, + InputNumber, + Switch, + Button, + Card, + Space, + Alert, + Result, + message, +} from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api'; + +interface RegisterData { + name: string; + slug: string; + domain: string; + basePath: string; + composeProject: string; + portConfig: { api: number; admin: number; postgres: number; nginx: number }; + adminEmail: string; + enableMedia: boolean; + enableChat: boolean; + enableGancio: boolean; + enableListmonk: boolean; + enableMonitoring: boolean; + enableDevTools: boolean; + enablePayments: boolean; + notes: string; +} + +const defaultData: RegisterData = { + name: '', + slug: '', + domain: '', + basePath: '/home/bunker-admin/changemaker.lite', + composeProject: 'changemakerlite', + portConfig: { api: 4002, admin: 3002, postgres: 5433, nginx: 80 }, + adminEmail: '', + enableMedia: false, + enableChat: false, + enableGancio: false, + enableListmonk: false, + enableMonitoring: false, + enableDevTools: false, + enablePayments: false, + notes: '', +}; + +export default function RegisterInstancePage() { + const navigate = useNavigate(); + const [data, setData] = useState(defaultData); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdId, setCreatedId] = useState(null); + + const update = (fields: Partial) => + setData((prev) => ({ ...prev, ...fields })); + + const updatePort = (key: keyof RegisterData['portConfig'], value: number | null) => + setData((prev) => ({ + ...prev, + portConfig: { ...prev.portConfig, [key]: value || 0 }, + })); + + const autoSlug = (name: string) => + name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + + const handleSubmit = async () => { + setSubmitting(true); + setError(null); + try { + const payload = { + ...data, + adminEmail: data.adminEmail || 'admin@localhost', + }; + const { data: result } = await api.post('/instances/register', payload); + setCreatedId(result.data.id); + message.success('Instance registered successfully'); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response + ?.data?.error; + setError(resp?.message || 'Failed to register instance'); + } finally { + setSubmitting(false); + } + }; + + const canSubmit = data.name && data.slug && data.domain && data.basePath && data.composeProject; + + if (createdId) { + return ( + navigate(`/app/instances/${createdId}`)}> + View Instance + , + , + ]} + /> + ); + } + + return ( +
+ + + +
+ + + ); +} diff --git a/changemaker-control-panel/admin/src/pages/SettingsPage.tsx b/changemaker-control-panel/admin/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..0f3257be --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/SettingsPage.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { Typography, Card, Form, Input, Button, Space, message, Descriptions } from 'antd'; +import { api } from '@/lib/api'; + +export default function SettingsPage() { + const [settings, setSettings] = useState>({}); + const [loading, setLoading] = useState(true); + const [savingPangolin, setSavingPangolin] = useState(false); + const [savingDefaults, setSavingDefaults] = useState(false); + + useEffect(() => { + api.get('/settings').then(({ data }) => { + setSettings(data.data); + setLoading(false); + }).catch(() => { + message.error('Failed to load settings'); + setLoading(false); + }); + }, []); + + const saveMultipleSettings = async (keys: string[], setLoading: (v: boolean) => void) => { + setLoading(true); + try { + await Promise.all(keys.map((key) => api.put(`/settings/${key}`, { value: settings[key] }))); + message.success('Settings saved'); + } catch { + message.error('Failed to save some settings'); + } finally { + setLoading(false); + } + }; + + return ( +
+ Settings + + + + + 14000 - 14999 + 13000 - 13999 + 15400 - 15499 + 10000 - 10999 + + + Port ranges are configured via environment variables and cannot be changed at runtime. + + + + +
+ + setSettings({ ...settings, pangolinApiUrl: e.target.value })} + placeholder="https://api.pangolin.example.com/v1" + /> + + + setSettings({ ...settings, pangolinApiKey: e.target.value })} + placeholder="API key" + /> + + + setSettings({ ...settings, pangolinOrgId: e.target.value })} + placeholder="Organization ID" + /> + + + +
+ + +
+ + setSettings({ ...settings, defaultGitBranch: e.target.value })} + /> + + + setSettings({ ...settings, instancesBasePath: e.target.value })} + /> + + + +
+
+
+ ); +} diff --git a/changemaker-control-panel/admin/src/stores/auth.store.ts b/changemaker-control-panel/admin/src/stores/auth.store.ts new file mode 100644 index 00000000..5ef803f3 --- /dev/null +++ b/changemaker-control-panel/admin/src/stores/auth.store.ts @@ -0,0 +1,142 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { api, registerAuthCallbacks } from '@/lib/api'; + +interface User { + id: string; + email: string; + name: string; + role: string; +} + +interface AuthState { + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; +} + +interface AuthActions { + login: (email: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; + hydrate: () => Promise; + clearAuth: () => void; + setTokens: (accessToken: string, refreshToken: string) => void; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: true, + error: null, + + login: async (email: string, password: string) => { + set({ error: null, isLoading: true }); + try { + const { data } = await api.post('/auth/login', { email, password }); + set({ + user: data.user, + accessToken: data.accessToken || null, + refreshToken: data.refreshToken || null, + isAuthenticated: true, + isLoading: false, + }); + } catch (err: unknown) { + const resp = (err as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error; + set({ error: resp?.message || 'Login failed', isLoading: false }); + throw err; + } + }, + + logout: async () => { + const { refreshToken } = get(); + try { + if (refreshToken) { + await api.post('/auth/logout', { refreshToken }); + } + } catch { + // Ignore logout errors + } + get().clearAuth(); + }, + + refresh: async () => { + const { refreshToken } = get(); + if (!refreshToken) { + get().clearAuth(); + return; + } + try { + const { data } = await api.post('/auth/refresh', { refreshToken }); + set({ + user: data.user, + accessToken: data.accessToken || null, + refreshToken: data.refreshToken || null, + isAuthenticated: true, + }); + } catch { + get().clearAuth(); + } + }, + + hydrate: async () => { + const { accessToken } = get(); + if (!accessToken) { + set({ isLoading: false }); + return; + } + try { + const { data } = await api.get('/auth/me'); + set({ user: data.user, isAuthenticated: true, isLoading: false }); + } catch { + get().clearAuth(); + } + }, + + clearAuth: () => { + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + }, + + setTokens: (accessToken: string, refreshToken: string) => { + set({ accessToken, refreshToken }); + }, + }), + { + name: 'ccp-auth', + partialize: (state) => ({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + }), + } + ) +); + +// Register callbacks to break circular dependency +registerAuthCallbacks({ + getTokens: () => { + const state = useAuthStore.getState(); + return { accessToken: state.accessToken, refreshToken: state.refreshToken }; + }, + onTokenRefresh: (accessToken, refreshToken) => { + useAuthStore.getState().setTokens(accessToken, refreshToken); + }, + onAuthFailure: () => { + useAuthStore.getState().clearAuth(); + window.location.href = '/login'; + }, +}); diff --git a/changemaker-control-panel/admin/src/types/api.ts b/changemaker-control-panel/admin/src/types/api.ts new file mode 100644 index 00000000..c207c732 --- /dev/null +++ b/changemaker-control-panel/admin/src/types/api.ts @@ -0,0 +1,135 @@ +export interface Instance { + id: string; + slug: string; + name: string; + domain: string; + status: 'PROVISIONING' | 'RUNNING' | 'STOPPED' | 'ERROR' | 'DESTROYING'; + statusMessage?: string; + basePath: string; + composeProject: string; + gitBranch: string; + gitCommit?: string; + portConfig: Record; + enableMedia: boolean; + enableChat: boolean; + enableGancio: boolean; + enableListmonk: boolean; + enableMonitoring: boolean; + enableDevTools: boolean; + enablePayments: boolean; + isRegistered: boolean; + adminEmail: string; + pangolinSiteId?: string; + pangolinNewtId?: string; + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpFrom?: string; + emailTestMode: boolean; + notes?: string; + createdAt: string; + updatedAt: string; + lastHealthCheck?: string; + portAllocations?: PortAllocation[]; + healthChecks?: HealthCheck[]; + backups?: Backup[]; + _count?: { healthChecks: number; backups: number }; +} + +export interface PortAllocation { + id: string; + port: number; + instanceId: string; + service: string; + notes?: string; +} + +export interface HealthCheck { + id: string; + instanceId: string; + status: 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY' | 'UNKNOWN'; + serviceStatus: Record; + totalServices: number; + healthyServices: number; + responseTimeMs?: number; + checkedAt: string; +} + +export interface Backup { + id: string; + instanceId: string; + status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; + archivePath?: string; + sizeBytes?: number; + manifest?: Record; + startedAt: string; + completedAt?: string; + errorMessage?: string; + s3Uploaded: boolean; +} + +// ─── Discovery Types ────────────────────────────────────────────── + +export interface DiscoveredInstance { + name: string; + slug: string; + domain: string; + basePath: string; + composeProject: string; + portConfig: { api: number; admin: number; postgres: number; nginx: number }; + adminEmail: string; + enableMedia: boolean; + enableChat: boolean; + enableGancio: boolean; + enableListmonk: boolean; + enableMonitoring: boolean; + enableDevTools: boolean; + enablePayments: boolean; + emailTestMode: boolean; + source: 'parent' | 'docker'; + isRunning: boolean; + runningContainers: number; + totalContainers: number; + isAlreadyRegistered: boolean; + existingInstanceId?: string; + isParentInstance: boolean; +} + +export interface DiscoverySummary { + total: number; + newInstances: number; + alreadyRegistered: number; + running: number; + parentFound: boolean; +} + +export interface DiscoveryResult { + instances: DiscoveredInstance[]; + summary: DiscoverySummary; +} + +export interface ImportResultItem { + slug: string; + success: boolean; + instanceId?: string; + error?: string; +} + +export interface ImportResult { + results: ImportResultItem[]; + summary: { total: number; succeeded: number; failed: number }; +} + +// ─── Audit Types ────────────────────────────────────────────────── + +export interface AuditLogEntry { + id: string; + userId?: string; + instanceId?: string; + action: string; + details?: Record; + ipAddress?: string; + createdAt: string; + user?: { id: string; email: string; name: string } | null; + instance?: { id: string; name: string; slug: string } | null; +} diff --git a/changemaker-control-panel/admin/src/vite-env.d.ts b/changemaker-control-panel/admin/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/changemaker-control-panel/admin/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/changemaker-control-panel/admin/tsconfig.json b/changemaker-control-panel/admin/tsconfig.json new file mode 100644 index 00000000..e030f3d4 --- /dev/null +++ b/changemaker-control-panel/admin/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/changemaker-control-panel/admin/vite.config.ts b/changemaker-control-panel/admin/vite.config.ts new file mode 100644 index 00000000..4680af26 --- /dev/null +++ b/changemaker-control-panel/admin/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5100, + proxy: { + '/api': { + target: process.env.VITE_API_URL || 'http://localhost:5000', + changeOrigin: true, + }, + }, + }, +}); diff --git a/changemaker-control-panel/api/package-lock.json b/changemaker-control-panel/api/package-lock.json new file mode 100644 index 00000000..2591306e --- /dev/null +++ b/changemaker-control-panel/api/package-lock.json @@ -0,0 +1,2464 @@ +{ + "name": "ccp-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ccp-api", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^6.3.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.5", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-async-errors": "^3.1.1", + "express-rate-limit": "^7.5.0", + "handlebars": "^4.7.8", + "helmet": "^8.0.0", + "ioredis": "^5.4.2", + "jsonwebtoken": "^9.0.2", + "rate-limit-redis": "^4.2.0", + "winston": "^3.17.0", + "yaml": "^2.8.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.0.0", + "prisma": "^6.3.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==" + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "devOptional": true, + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "peerDependencies": { + "express": "^4.16.2" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ioredis": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", + "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true + }, + "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==", + "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", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limit-redis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz", + "integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express-rate-limit": ">= 6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "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==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "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==", + "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/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "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==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "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==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/changemaker-control-panel/api/package.json b/changemaker-control-panel/api/package.json new file mode 100644 index 00000000..ce3c0d8c --- /dev/null +++ b/changemaker-control-panel/api/package.json @@ -0,0 +1,45 @@ +{ + "name": "ccp-api", + "version": "1.0.0", + "description": "Changemaker Control Panel — API Server", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "db:migrate": "prisma migrate deploy", + "db:migrate:dev": "prisma migrate dev", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@prisma/client": "^6.3.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.5", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-async-errors": "^3.1.1", + "express-rate-limit": "^7.5.0", + "handlebars": "^4.7.8", + "helmet": "^8.0.0", + "ioredis": "^5.4.2", + "jsonwebtoken": "^9.0.2", + "rate-limit-redis": "^4.2.0", + "winston": "^3.17.0", + "yaml": "^2.8.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.0.0", + "prisma": "^6.3.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/changemaker-control-panel/api/prisma/migrations/0001_init/migration.sql b/changemaker-control-panel/api/prisma/migrations/0001_init/migration.sql new file mode 100644 index 00000000..e33d5571 --- /dev/null +++ b/changemaker-control-panel/api/prisma/migrations/0001_init/migration.sql @@ -0,0 +1,203 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "CcpRole" AS ENUM ('SUPER_ADMIN', 'OPERATOR', 'VIEWER'); + +-- CreateEnum +CREATE TYPE "InstanceStatus" AS ENUM ('PROVISIONING', 'RUNNING', 'STOPPED', 'ERROR', 'DESTROYING'); + +-- CreateEnum +CREATE TYPE "HealthStatus" AS ENUM ('HEALTHY', 'DEGRADED', 'UNHEALTHY', 'UNKNOWN'); + +-- CreateEnum +CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "AuditAction" AS ENUM ('INSTANCE_CREATE', 'INSTANCE_UPDATE', 'INSTANCE_DELETE', 'INSTANCE_START', 'INSTANCE_STOP', 'INSTANCE_RESTART', 'INSTANCE_UPGRADE', 'BACKUP_CREATE', 'BACKUP_DELETE', 'PANGOLIN_SETUP', 'PANGOLIN_SYNC', 'USER_LOGIN', 'USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'SETTINGS_UPDATE'); + +-- CreateTable +CREATE TABLE "ccp_users" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" "CcpRole" NOT NULL DEFAULT 'OPERATOR', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ccp_users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ccp_refresh_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ccp_refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "instances" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "domain" TEXT NOT NULL, + "status" "InstanceStatus" NOT NULL DEFAULT 'PROVISIONING', + "status_message" TEXT, + "base_path" TEXT NOT NULL, + "compose_project" TEXT NOT NULL, + "git_branch" TEXT NOT NULL DEFAULT 'v2', + "git_commit" TEXT, + "port_config" JSONB NOT NULL, + "encrypted_secrets" TEXT NOT NULL, + "enable_media" BOOLEAN NOT NULL DEFAULT false, + "enable_chat" BOOLEAN NOT NULL DEFAULT false, + "enable_gancio" BOOLEAN NOT NULL DEFAULT false, + "enable_listmonk" BOOLEAN NOT NULL DEFAULT false, + "enable_monitoring" BOOLEAN NOT NULL DEFAULT false, + "admin_email" TEXT NOT NULL, + "pangolin_site_id" TEXT, + "pangolin_newt_id" TEXT, + "pangolin_newt_secret" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_user" TEXT, + "smtp_from" TEXT, + "email_test_mode" BOOLEAN NOT NULL DEFAULT true, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "last_health_check" TIMESTAMP(3), + + CONSTRAINT "instances_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "port_allocations" ( + "id" TEXT NOT NULL, + "port" INTEGER NOT NULL, + "instance_id" TEXT NOT NULL, + "service" TEXT NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "port_allocations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "health_checks" ( + "id" TEXT NOT NULL, + "instance_id" TEXT NOT NULL, + "status" "HealthStatus" NOT NULL, + "service_status" JSONB NOT NULL, + "total_services" INTEGER NOT NULL, + "healthy_services" INTEGER NOT NULL, + "response_time_ms" INTEGER, + "checked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "health_checks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "backups" ( + "id" TEXT NOT NULL, + "instance_id" TEXT NOT NULL, + "status" "BackupStatus" NOT NULL DEFAULT 'PENDING', + "archive_path" TEXT, + "size_bytes" BIGINT, + "manifest" JSONB, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "error_message" TEXT, + "s3_uploaded" BOOLEAN NOT NULL DEFAULT false, + "s3_key" TEXT, + + CONSTRAINT "backups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "instance_id" TEXT, + "action" "AuditAction" NOT NULL, + "details" JSONB, + "ip_address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ccp_settings" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ccp_settings_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ccp_users_email_key" ON "ccp_users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "ccp_refresh_tokens_token_key" ON "ccp_refresh_tokens"("token"); + +-- CreateIndex +CREATE INDEX "ccp_refresh_tokens_user_id_idx" ON "ccp_refresh_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "ccp_refresh_tokens_expires_at_idx" ON "ccp_refresh_tokens"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "instances_slug_key" ON "instances"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "instances_domain_key" ON "instances"("domain"); + +-- CreateIndex +CREATE UNIQUE INDEX "instances_compose_project_key" ON "instances"("compose_project"); + +-- CreateIndex +CREATE UNIQUE INDEX "port_allocations_port_key" ON "port_allocations"("port"); + +-- CreateIndex +CREATE INDEX "port_allocations_instance_id_idx" ON "port_allocations"("instance_id"); + +-- CreateIndex +CREATE INDEX "health_checks_instance_id_checked_at_idx" ON "health_checks"("instance_id", "checked_at"); + +-- CreateIndex +CREATE INDEX "backups_instance_id_started_at_idx" ON "backups"("instance_id", "started_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_instance_id_created_at_idx" ON "audit_logs"("instance_id", "created_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_user_id_created_at_idx" ON "audit_logs"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_action_created_at_idx" ON "audit_logs"("action", "created_at"); + +-- AddForeignKey +ALTER TABLE "ccp_refresh_tokens" ADD CONSTRAINT "ccp_refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ccp_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "port_allocations" ADD CONSTRAINT "port_allocations_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "health_checks" ADD CONSTRAINT "health_checks_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "backups" ADD CONSTRAINT "backups_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ccp_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/changemaker-control-panel/api/prisma/migrations/20260220021454_add_is_registered/migration.sql b/changemaker-control-panel/api/prisma/migrations/20260220021454_add_is_registered/migration.sql new file mode 100644 index 00000000..9a278e04 --- /dev/null +++ b/changemaker-control-panel/api/prisma/migrations/20260220021454_add_is_registered/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "instances" ADD COLUMN "is_registered" BOOLEAN NOT NULL DEFAULT false, +ALTER COLUMN "encrypted_secrets" DROP NOT NULL; diff --git a/changemaker-control-panel/api/prisma/migrations/20260220041455_add_feature_flags/migration.sql b/changemaker-control-panel/api/prisma/migrations/20260220041455_add_feature_flags/migration.sql new file mode 100644 index 00000000..03b107c7 --- /dev/null +++ b/changemaker-control-panel/api/prisma/migrations/20260220041455_add_feature_flags/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "instances" ADD COLUMN "enable_dev_tools" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "enable_payments" BOOLEAN NOT NULL DEFAULT false; diff --git a/changemaker-control-panel/api/prisma/migrations/migration_lock.toml b/changemaker-control-panel/api/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/changemaker-control-panel/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/changemaker-control-panel/api/prisma/schema.prisma b/changemaker-control-panel/api/prisma/schema.prisma new file mode 100644 index 00000000..00ddfddf --- /dev/null +++ b/changemaker-control-panel/api/prisma/schema.prisma @@ -0,0 +1,232 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ─── CCP Users (control panel operators) ─────────────────── + +enum CcpRole { + SUPER_ADMIN + OPERATOR + VIEWER +} + +model CcpUser { + id String @id @default(uuid()) + email String @unique + password String + name String + role CcpRole @default(OPERATOR) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + refreshTokens CcpRefreshToken[] + auditLogs AuditLog[] + + @@map("ccp_users") +} + +model CcpRefreshToken { + id String @id @default(uuid()) + token String @unique + userId String @map("user_id") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + user CcpUser @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@map("ccp_refresh_tokens") +} + +// ─── Managed Instances ───────────────────────────────────── + +enum InstanceStatus { + PROVISIONING + RUNNING + STOPPED + ERROR + DESTROYING +} + +model Instance { + id String @id @default(uuid()) + slug String @unique + name String + domain String @unique + status InstanceStatus @default(PROVISIONING) + statusMessage String? @map("status_message") + + basePath String @map("base_path") + composeProject String @unique @map("compose_project") + gitBranch String @default("v2") @map("git_branch") + gitCommit String? @map("git_commit") + + // Allocated host ports (JSON: { api: 14001, admin: 13001, postgres: 15401, nginx: 10001 }) + portConfig Json @map("port_config") + + // AES-256-GCM encrypted JSON blob of all instance secrets (null for registered instances) + encryptedSecrets String? @map("encrypted_secrets") + + // True if this instance was registered externally (not provisioned by CCP) + isRegistered Boolean @default(false) @map("is_registered") + + // Feature flags + enableMedia Boolean @default(false) @map("enable_media") + enableChat Boolean @default(false) @map("enable_chat") + enableGancio Boolean @default(false) @map("enable_gancio") + enableListmonk Boolean @default(false) @map("enable_listmonk") + enableMonitoring Boolean @default(false) @map("enable_monitoring") + enableDevTools Boolean @default(false) @map("enable_dev_tools") + enablePayments Boolean @default(false) @map("enable_payments") + + // Admin config + adminEmail String @map("admin_email") + + // Pangolin tunnel + pangolinSiteId String? @map("pangolin_site_id") + pangolinNewtId String? @map("pangolin_newt_id") + pangolinNewtSecret String? @map("pangolin_newt_secret") + + // SMTP + smtpHost String? @map("smtp_host") + smtpPort Int? @map("smtp_port") + smtpUser String? @map("smtp_user") + smtpFrom String? @map("smtp_from") + emailTestMode Boolean @default(true) @map("email_test_mode") + + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lastHealthCheck DateTime? @map("last_health_check") + + portAllocations PortAllocation[] + healthChecks HealthCheck[] + backups Backup[] + auditLogs AuditLog[] + + @@map("instances") +} + +// ─── Port Allocation ─────────────────────────────────────── + +model PortAllocation { + id String @id @default(uuid()) + port Int @unique + instanceId String @map("instance_id") + service String + notes String? + createdAt DateTime @default(now()) @map("created_at") + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + + @@index([instanceId]) + @@map("port_allocations") +} + +// ─── Health Checks ───────────────────────────────────────── + +enum HealthStatus { + HEALTHY + DEGRADED + UNHEALTHY + UNKNOWN +} + +model HealthCheck { + id String @id @default(uuid()) + instanceId String @map("instance_id") + status HealthStatus + serviceStatus Json @map("service_status") + totalServices Int @map("total_services") + healthyServices Int @map("healthy_services") + responseTimeMs Int? @map("response_time_ms") + checkedAt DateTime @default(now()) @map("checked_at") + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + + @@index([instanceId, checkedAt]) + @@map("health_checks") +} + +// ─── Backups ─────────────────────────────────────────────── + +enum BackupStatus { + PENDING + IN_PROGRESS + COMPLETED + FAILED +} + +model Backup { + id String @id @default(uuid()) + instanceId String @map("instance_id") + status BackupStatus @default(PENDING) + archivePath String? @map("archive_path") + sizeBytes BigInt? @map("size_bytes") + manifest Json? + startedAt DateTime @default(now()) @map("started_at") + completedAt DateTime? @map("completed_at") + errorMessage String? @map("error_message") + s3Uploaded Boolean @default(false) @map("s3_uploaded") + s3Key String? @map("s3_key") + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + + @@index([instanceId, startedAt]) + @@map("backups") +} + +// ─── Audit Log ───────────────────────────────────────────── + +enum AuditAction { + INSTANCE_CREATE + INSTANCE_UPDATE + INSTANCE_DELETE + INSTANCE_START + INSTANCE_STOP + INSTANCE_RESTART + INSTANCE_UPGRADE + BACKUP_CREATE + BACKUP_DELETE + PANGOLIN_SETUP + PANGOLIN_SYNC + USER_LOGIN + USER_CREATE + USER_UPDATE + USER_DELETE + SETTINGS_UPDATE +} + +model AuditLog { + id String @id @default(uuid()) + userId String? @map("user_id") + instanceId String? @map("instance_id") + action AuditAction + details Json? + ipAddress String? @map("ip_address") + createdAt DateTime @default(now()) @map("created_at") + + user CcpUser? @relation(fields: [userId], references: [id], onDelete: SetNull) + instance Instance? @relation(fields: [instanceId], references: [id], onDelete: SetNull) + + @@index([instanceId, createdAt]) + @@index([userId, createdAt]) + @@index([action, createdAt]) + @@map("audit_logs") +} + +// ─── CCP Settings ────────────────────────────────────────── + +model CcpSetting { + key String @id + value Json + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("ccp_settings") +} diff --git a/changemaker-control-panel/api/prisma/seed.ts b/changemaker-control-panel/api/prisma/seed.ts new file mode 100644 index 00000000..18f6a479 --- /dev/null +++ b/changemaker-control-panel/api/prisma/seed.ts @@ -0,0 +1,49 @@ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + const email = process.env.INITIAL_ADMIN_EMAIL || 'admin@example.com'; + const password = process.env.INITIAL_ADMIN_PASSWORD || 'ChangeMe2025!!'; + + // Create initial admin user + const existing = await prisma.ccpUser.findUnique({ where: { email } }); + if (!existing) { + const hashedPassword = await bcrypt.hash(password, 12); + await prisma.ccpUser.create({ + data: { + email, + password: hashedPassword, + name: 'Admin', + role: 'SUPER_ADMIN', + }, + }); + console.log(`Created initial admin user: ${email}`); + } else { + console.log(`Admin user already exists: ${email}`); + } + + // Create default settings + const defaults: Record = { + defaultGitBranch: 'v2', + instancesBasePath: process.env.INSTANCES_BASE_PATH || 'instances', + }; + + for (const [key, value] of Object.entries(defaults)) { + await prisma.ccpSetting.upsert({ + where: { key }, + update: {}, + create: { key, value: value as string }, + }); + } + console.log('Default settings seeded'); +} + +main() + .catch((e) => { + console.error('Seed error:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/changemaker-control-panel/api/src/config/env.ts b/changemaker-control-panel/api/src/config/env.ts new file mode 100644 index 00000000..cec2e5c3 --- /dev/null +++ b/changemaker-control-panel/api/src/config/env.ts @@ -0,0 +1,80 @@ +import 'dotenv/config'; +import path from 'path'; +import { z } from 'zod'; + +const envSchema = z.object({ + // Server + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().default(5000), + + // Database + DATABASE_URL: z.string().url(), + + // Redis + REDIS_URL: z.string().default('redis://localhost:6399'), + + // JWT + JWT_ACCESS_SECRET: z.string().min(32), + JWT_REFRESH_SECRET: z.string().min(32), + JWT_ACCESS_EXPIRES_IN: z.string().default('15m'), + JWT_REFRESH_EXPIRES_IN: z.string().default('7d'), + + // Encryption key for secrets at rest (64 hex chars = 32 bytes for AES-256) + ENCRYPTION_KEY: z.string().min(64).regex(/^[0-9a-f]+$/i, 'Must be hex-encoded (use: openssl rand -hex 32)'), + + // Initial admin + INITIAL_ADMIN_EMAIL: z.string().email().default('admin@example.com'), + INITIAL_ADMIN_PASSWORD: z.string().min(12).default('ChangeMe2025!!'), + + // CORS + CORS_ORIGINS: z.string().default('http://localhost:5100'), + + // Instance management (resolved by setup.sh; fallback for local dev) + INSTANCES_BASE_PATH: z.string().default( + path.resolve(process.cwd(), '..', 'instances') + ), + CML_SOURCE_PATH: z.string().default(''), + CML_GIT_REPO: z.string().default(''), + CML_GIT_BRANCH: z.string().default('v2'), + + // Port allocation ranges + PORT_RANGE_API_START: z.coerce.number().default(14000), + PORT_RANGE_API_END: z.coerce.number().default(14999), + PORT_RANGE_ADMIN_START: z.coerce.number().default(13000), + PORT_RANGE_ADMIN_END: z.coerce.number().default(13999), + PORT_RANGE_POSTGRES_START: z.coerce.number().default(15400), + PORT_RANGE_POSTGRES_END: z.coerce.number().default(15499), + PORT_RANGE_NGINX_START: z.coerce.number().default(10000), + PORT_RANGE_NGINX_END: z.coerce.number().default(10999), + PORT_RANGE_EMBED_START: z.coerce.number().default(12000), + PORT_RANGE_EMBED_END: z.coerce.number().default(12499), + + // Pangolin (optional) + PANGOLIN_API_URL: z.string().default(''), + PANGOLIN_API_KEY: z.string().default(''), + PANGOLIN_ORG_ID: z.string().default(''), + + // Health checks + HEALTH_CHECK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 min (0 to disable) + + // Backups + BACKUP_STORAGE_PATH: z.string().default( + path.resolve(process.cwd(), '..', 'backups') + ), + BACKUP_RETENTION_DAYS: z.coerce.number().default(30), +}); + +function validateEnv() { + const result = envSchema.safeParse(process.env); + if (!result.success) { + console.error('❌ Invalid environment variables:'); + for (const [key, errors] of Object.entries(result.error.flatten().fieldErrors)) { + console.error(` ${key}: ${errors?.join(', ')}`); + } + process.exit(1); + } + return result.data; +} + +export const env = validateEnv(); +export type Env = z.infer; diff --git a/changemaker-control-panel/api/src/config/redis.ts b/changemaker-control-panel/api/src/config/redis.ts new file mode 100644 index 00000000..2a7817ab --- /dev/null +++ b/changemaker-control-panel/api/src/config/redis.ts @@ -0,0 +1,14 @@ +import Redis from 'ioredis'; +import { env } from './env'; +import { logger } from '../utils/logger'; + +export const redis = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: 3, + retryStrategy(times) { + if (times > 10) return null; + return Math.min(times * 200, 5000); + }, +}); + +redis.on('connect', () => logger.info('Redis connected')); +redis.on('error', (err) => logger.error('Redis error:', err.message)); diff --git a/changemaker-control-panel/api/src/lib/prisma.ts b/changemaker-control-panel/api/src/lib/prisma.ts new file mode 100644 index 00000000..9b6c4ce3 --- /dev/null +++ b/changemaker-control-panel/api/src/lib/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient(); diff --git a/changemaker-control-panel/api/src/middleware/auth.ts b/changemaker-control-panel/api/src/middleware/auth.ts new file mode 100644 index 00000000..c58a0f01 --- /dev/null +++ b/changemaker-control-panel/api/src/middleware/auth.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { CcpRole } from '@prisma/client'; +import { env } from '../config/env'; +import { AppError } from './error-handler'; + +interface TokenPayload { + id: string; + email: string; + role: CcpRole; +} + +declare global { + namespace Express { + interface Request { + user?: TokenPayload; + } + } +} + +export function authenticate(req: Request, _res: Response, next: NextFunction) { + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) { + throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED'); + } + + const token = header.slice(7); + try { + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + req.user = { + id: payload.id, + email: payload.email, + role: payload.role, + }; + next(); + } catch { + throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN'); + } +} + +export function requireRole(...roles: CcpRole[]) { + return (req: Request, _res: Response, next: NextFunction) => { + if (!req.user) { + throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED'); + } + if (!roles.includes(req.user.role)) { + throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN'); + } + next(); + }; +} diff --git a/changemaker-control-panel/api/src/middleware/error-handler.ts b/changemaker-control-panel/api/src/middleware/error-handler.ts new file mode 100644 index 00000000..746a623b --- /dev/null +++ b/changemaker-control-panel/api/src/middleware/error-handler.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodError } from 'zod'; +import { env } from '../config/env'; +import { logger } from '../utils/logger'; + +export class AppError extends Error { + constructor( + public statusCode: number, + message: string, + public code?: string + ) { + super(message); + this.name = 'AppError'; + } +} + +export function errorHandler( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +) { + if (err instanceof AppError) { + res.status(err.statusCode).json({ + error: { message: err.message, code: err.code }, + }); + return; + } + + if (err instanceof ZodError) { + const fieldErrors = err.flatten().fieldErrors; + const errorCount = Object.keys(fieldErrors).length; + res.status(400).json({ + error: { + message: 'Validation error', + code: 'VALIDATION_ERROR', + ...(env.NODE_ENV === 'development' && { details: fieldErrors }), + ...(env.NODE_ENV === 'production' && { fieldCount: errorCount }), + }, + }); + return; + } + + logger.error('Unhandled error:', err); + res.status(500).json({ + error: { + message: env.NODE_ENV === 'production' ? 'Internal server error' : err.message, + code: 'INTERNAL_ERROR', + }, + }); +} diff --git a/changemaker-control-panel/api/src/middleware/validate.ts b/changemaker-control-panel/api/src/middleware/validate.ts new file mode 100644 index 00000000..6f5aa934 --- /dev/null +++ b/changemaker-control-panel/api/src/middleware/validate.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodSchema } from 'zod'; + +export function validate(schema: ZodSchema) { + return (req: Request, _res: Response, next: NextFunction) => { + schema.parse(req.body); + next(); + }; +} + +export function validateQuery(schema: ZodSchema) { + return (req: Request, _res: Response, next: NextFunction) => { + schema.parse(req.query); + next(); + }; +} diff --git a/changemaker-control-panel/api/src/modules/audit/audit.routes.ts b/changemaker-control-panel/api/src/modules/audit/audit.routes.ts new file mode 100644 index 00000000..47b76c2d --- /dev/null +++ b/changemaker-control-panel/api/src/modules/audit/audit.routes.ts @@ -0,0 +1,30 @@ +import { Router, Request, Response } from 'express'; +import { AuditAction } from '@prisma/client'; +import { authenticate, requireRole } from '../../middleware/auth'; +import * as auditService from './audit.service'; + +const router = Router(); + +router.use(authenticate); + +router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { + const { action, instanceId, userId, from, to, page, limit } = req.query; + + const filters = { + action: action && Object.values(AuditAction).includes(action as AuditAction) + ? (action as AuditAction) + : undefined, + instanceId: instanceId as string | undefined, + userId: userId as string | undefined, + from: from as string | undefined, + to: to as string | undefined, + }; + + const pageNum = Math.max(1, parseInt(page as string, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 50)); + + const result = await auditService.listAuditLogs(filters, pageNum, limitNum); + res.json(result); +}); + +export default router; diff --git a/changemaker-control-panel/api/src/modules/audit/audit.service.ts b/changemaker-control-panel/api/src/modules/audit/audit.service.ts new file mode 100644 index 00000000..77ceedaa --- /dev/null +++ b/changemaker-control-panel/api/src/modules/audit/audit.service.ts @@ -0,0 +1,43 @@ +import { AuditAction, Prisma } from '@prisma/client'; +import { prisma } from '../../lib/prisma'; + +interface AuditFilters { + action?: AuditAction; + instanceId?: string; + userId?: string; + from?: string; + to?: string; +} + +export async function listAuditLogs( + filters: AuditFilters, + page = 1, + limit = 50 +) { + const where: Prisma.AuditLogWhereInput = {}; + + if (filters.action) where.action = filters.action; + if (filters.instanceId) where.instanceId = filters.instanceId; + if (filters.userId) where.userId = filters.userId; + if (filters.from || filters.to) { + where.createdAt = {}; + if (filters.from) where.createdAt.gte = new Date(filters.from); + if (filters.to) where.createdAt.lte = new Date(filters.to); + } + + const [data, total] = await Promise.all([ + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { + user: { select: { id: true, email: true, name: true } }, + instance: { select: { id: true, name: true, slug: true } }, + }, + }), + prisma.auditLog.count({ where }), + ]); + + return { data, total, page, limit }; +} diff --git a/changemaker-control-panel/api/src/modules/auth/auth.routes.ts b/changemaker-control-panel/api/src/modules/auth/auth.routes.ts new file mode 100644 index 00000000..6bf28e11 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/auth/auth.routes.ts @@ -0,0 +1,59 @@ +import { Router, Request, Response } from 'express'; +import { AuditAction } from '@prisma/client'; +import { prisma } from '../../lib/prisma'; +import { authenticate } from '../../middleware/auth'; +import { validate } from '../../middleware/validate'; +import { loginSchema, refreshSchema, logoutSchema, verifyPasswordSchema } from './auth.schemas'; +import * as authService from './auth.service'; +const router = Router(); + +router.post('/login', validate(loginSchema), async (req: Request, res: Response) => { + const { email, password } = req.body; + const result = await authService.login(email, password); + + // Audit log the login + await prisma.auditLog.create({ + data: { + userId: result.user.id, + action: AuditAction.USER_LOGIN, + details: { email: result.user.email }, + ipAddress: req.ip, + }, + }); + + res.json(result); +}); + +router.post('/refresh', validate(refreshSchema), async (req: Request, res: Response) => { + const { refreshToken } = req.body; + const result = await authService.refresh(refreshToken); + res.json(result); +}); + +router.post('/logout', validate(logoutSchema), async (req: Request, res: Response) => { + const { refreshToken } = req.body; + await authService.logout(refreshToken); + res.json({ message: 'Logged out' }); +}); + +router.post( + '/verify-password', + authenticate, + validate(verifyPasswordSchema), + async (req: Request, res: Response) => { + const { password } = req.body; + const valid = await authService.verifyPassword(req.user!.id, password); + if (!valid) { + res.status(401).json({ error: { message: 'Invalid password', code: 'INVALID_PASSWORD' } }); + return; + } + res.json({ verified: true }); + } +); + +router.get('/me', authenticate, async (req: Request, res: Response) => { + const user = await authService.getMe(req.user!.id); + res.json({ user }); +}); + +export default router; diff --git a/changemaker-control-panel/api/src/modules/auth/auth.schemas.ts b/changemaker-control-panel/api/src/modules/auth/auth.schemas.ts new file mode 100644 index 00000000..2415eff9 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/auth/auth.schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export const refreshSchema = z.object({ + refreshToken: z.string().min(1), +}); + +export const logoutSchema = z.object({ + refreshToken: z.string().min(1), +}); + +export const verifyPasswordSchema = z.object({ + password: z.string().min(1), +}); diff --git a/changemaker-control-panel/api/src/modules/auth/auth.service.ts b/changemaker-control-panel/api/src/modules/auth/auth.service.ts new file mode 100644 index 00000000..d1ab88d5 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/auth/auth.service.ts @@ -0,0 +1,131 @@ +import bcrypt from 'bcryptjs'; +import jwt, { SignOptions } from 'jsonwebtoken'; +import crypto from 'crypto'; +import { CcpRole } from '@prisma/client'; +import { prisma } from '../../lib/prisma'; +import { env } from '../../config/env'; +import { AppError } from '../../middleware/error-handler'; + +interface TokenPayload { + id: string; + email: string; + role: CcpRole; +} + +function signAccessToken(payload: TokenPayload): string { + return jwt.sign(payload, env.JWT_ACCESS_SECRET, { + expiresIn: env.JWT_ACCESS_EXPIRES_IN as SignOptions['expiresIn'], + }); +} + +function signRefreshToken(payload: TokenPayload): string { + return jwt.sign(payload, env.JWT_REFRESH_SECRET, { + expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'], + }); +} + +function parseExpiry(expiresIn: string): Date { + const match = expiresIn.match(/^(\d+)([smhd])$/); + if (!match) return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // default 7d + const [, num, unit] = match; + const multipliers: Record = { s: 1000, m: 60000, h: 3600000, d: 86400000 }; + return new Date(Date.now() + parseInt(num) * multipliers[unit]); +} + +export async function login(email: string, password: string) { + const user = await prisma.ccpUser.findUnique({ where: { email } }); + if (!user) { + throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS'); + } + + const valid = await bcrypt.compare(password, user.password); + if (!valid) { + throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS'); + } + + const payload: TokenPayload = { id: user.id, email: user.email, role: user.role }; + const accessToken = signAccessToken(payload); + const refreshToken = signRefreshToken(payload); + + // Store refresh token + await prisma.ccpRefreshToken.create({ + data: { + token: crypto.createHash('sha256').update(refreshToken).digest('hex'), + userId: user.id, + expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN), + }, + }); + + return { + user: { id: user.id, email: user.email, name: user.name, role: user.role }, + accessToken, + refreshToken, + }; +} + +export async function refresh(refreshToken: string) { + let payload: TokenPayload; + try { + payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload; + } catch { + throw new AppError(401, 'Invalid refresh token', 'INVALID_TOKEN'); + } + + const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + + // Atomic rotation: delete old, create new + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.ccpRefreshToken.findUnique({ where: { token: tokenHash } }); + if (!existing || existing.expiresAt < new Date()) { + throw new AppError(401, 'Refresh token expired or revoked', 'TOKEN_EXPIRED'); + } + + await tx.ccpRefreshToken.delete({ where: { token: tokenHash } }); + + const user = await tx.ccpUser.findUnique({ where: { id: payload.id } }); + if (!user) { + throw new AppError(401, 'User not found', 'USER_NOT_FOUND'); + } + + const newPayload: TokenPayload = { id: user.id, email: user.email, role: user.role }; + const newAccessToken = signAccessToken(newPayload); + const newRefreshToken = signRefreshToken(newPayload); + + await tx.ccpRefreshToken.create({ + data: { + token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'), + userId: user.id, + expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN), + }, + }); + + return { + user: { id: user.id, email: user.email, name: user.name, role: user.role }, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + }); + + return result; +} + +export async function logout(refreshToken: string) { + const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + await prisma.ccpRefreshToken.deleteMany({ where: { token: tokenHash } }); +} + +export async function verifyPassword(userId: string, password: string): Promise { + const user = await prisma.ccpUser.findUnique({ where: { id: userId } }); + if (!user) { + throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED'); + } + return bcrypt.compare(password, user.password); +} + +export async function getMe(userId: string) { + const user = await prisma.ccpUser.findUnique({ where: { id: userId } }); + if (!user) { + throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED'); + } + return { id: user.id, email: user.email, name: user.name, role: user.role }; +} diff --git a/changemaker-control-panel/api/src/modules/backups/backup.routes.ts b/changemaker-control-panel/api/src/modules/backups/backup.routes.ts new file mode 100644 index 00000000..1dcd5ad3 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/backups/backup.routes.ts @@ -0,0 +1,71 @@ +import { Router, Request, Response } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { authenticate, requireRole } from '../../middleware/auth'; +import { env } from '../../config/env'; +import * as backupService from '../../services/backup.service'; + +const router = Router(); + +router.use(authenticate); + +// ─── Cross-Instance Backup Endpoints ──────────────────────────────── + +// List all backups (cross-instance) +router.get('/', async (req: Request, res: Response) => { + const { instanceId, page, limit } = req.query; + const pageNum = Math.max(1, parseInt(page as string, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 50)); + + const result = await backupService.listBackups( + instanceId as string | undefined, + pageNum, + limitNum + ); + res.json(result); +}); + +// Delete a backup +router.delete( + '/:backupId', + requireRole('SUPER_ADMIN'), + async (req: Request, res: Response) => { + await backupService.deleteBackup(req.params.backupId as string, req.user!.id, req.ip); + res.json({ message: 'Backup deleted' }); + } +); + +// Download a backup archive +router.get( + '/:backupId/download', + requireRole('SUPER_ADMIN'), + async (req: Request, res: Response) => { + const backup = await backupService.getBackup(req.params.backupId as string); + + if (!backup.archivePath || backup.status !== 'COMPLETED') { + res.status(400).json({ error: { message: 'Backup not available for download', code: 'NOT_AVAILABLE' } }); + return; + } + + // Validate path is within backup storage (prevent traversal) + const normalized = path.resolve(backup.archivePath); + const normalizedStorage = path.resolve(env.BACKUP_STORAGE_PATH); + if (!normalized.startsWith(normalizedStorage + path.sep)) { + res.status(403).json({ error: { message: 'Access denied', code: 'FORBIDDEN' } }); + return; + } + if (!fs.existsSync(normalized)) { + res.status(404).json({ error: { message: 'Backup file not found', code: 'FILE_NOT_FOUND' } }); + return; + } + + const filename = path.basename(normalized); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Type', 'application/gzip'); + + const stream = fs.createReadStream(normalized); + stream.pipe(res); + } +); + +export default router; diff --git a/changemaker-control-panel/api/src/modules/health/health.routes.ts b/changemaker-control-panel/api/src/modules/health/health.routes.ts new file mode 100644 index 00000000..27731ccb --- /dev/null +++ b/changemaker-control-panel/api/src/modules/health/health.routes.ts @@ -0,0 +1,46 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../../lib/prisma'; +import { authenticate } from '../../middleware/auth'; +const router = Router(); + +// Public health endpoint for CCP itself +router.get('/', async (_req: Request, res: Response) => { + try { + await prisma.$queryRaw`SELECT 1`; + res.json({ status: 'healthy', timestamp: new Date().toISOString() }); + } catch { + res.status(503).json({ status: 'unhealthy', timestamp: new Date().toISOString() }); + } +}); + +// Authenticated: overview of all instances' health +router.get('/overview', authenticate, async (_req: Request, res: Response) => { + const instances = await prisma.instance.findMany({ + select: { + id: true, + name: true, + slug: true, + domain: true, + status: true, + lastHealthCheck: true, + healthChecks: { + orderBy: { checkedAt: 'desc' }, + take: 1, + }, + }, + }); + + const summary = instances.map((i) => ({ + id: i.id, + name: i.name, + slug: i.slug, + domain: i.domain, + status: i.status, + lastHealthCheck: i.lastHealthCheck, + health: i.healthChecks[0] || null, + })); + + res.json({ data: summary }); +}); + +export default router; diff --git a/changemaker-control-panel/api/src/modules/instances/instances.routes.ts b/changemaker-control-panel/api/src/modules/instances/instances.routes.ts new file mode 100644 index 00000000..253cffe6 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/instances/instances.routes.ts @@ -0,0 +1,278 @@ +import { Router, Request, Response } from 'express'; +import { AuditAction } from '@prisma/client'; +import rateLimit from 'express-rate-limit'; +import { prisma } from '../../lib/prisma'; +import { authenticate, requireRole } from '../../middleware/auth'; +import { validate } from '../../middleware/validate'; +import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, importInstancesSchema } from './instances.schemas'; +import * as instancesService from './instances.service'; +import * as healthService from '../../services/health.service'; +import * as backupService from '../../services/backup.service'; +import { discoverInstances } from '../../services/discovery.service'; + +const secretsLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: { message: 'Too many secrets requests, please try again later', code: 'RATE_LIMITED' } }, +}); + +const router = Router(); + +// All instance routes require authentication +router.use(authenticate); + +// ─── Discovery Endpoints ──────────────────────────────────────────── + +router.post( + '/discover', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (_req: Request, res: Response) => { + const result = await discoverInstances(); + res.json({ data: result }); + } +); + +router.post( + '/import', + requireRole('SUPER_ADMIN', 'OPERATOR'), + validate(importInstancesSchema), + async (req: Request, res: Response) => { + const { instances } = req.body as { instances: Array> }; + const results: Array<{ slug: string; success: boolean; instanceId?: string; error?: string }> = []; + + for (const inst of instances) { + try { + const registered = await instancesService.registerInstance( + inst as Parameters[0], + req.user!.id, + req.ip + ); + results.push({ slug: inst.slug as string, success: true, instanceId: registered?.id }); + } catch (err) { + results.push({ slug: inst.slug as string, success: false, error: (err as Error).message }); + } + } + + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + res.json({ + data: { + results, + summary: { total: results.length, succeeded, failed }, + }, + }); + } +); + +// ─── CRUD Endpoints ────────────────────────────────────────────────── + +router.get('/', async (_req: Request, res: Response) => { + const instances = await instancesService.listInstances(); + res.json({ data: instances }); +}); + +// Register an existing (externally-managed) instance for monitoring +router.post( + '/register', + requireRole('SUPER_ADMIN', 'OPERATOR'), + validate(registerInstanceSchema), + async (req: Request, res: Response) => { + const instance = await instancesService.registerInstance(req.body, req.user!.id, req.ip); + res.status(201).json({ data: instance }); + } +); + +router.get('/:id', async (req: Request, res: Response) => { + const instance = await instancesService.getInstance(req.params.id as string); + res.json({ data: instance }); +}); + +router.post( + '/', + requireRole('SUPER_ADMIN', 'OPERATOR'), + validate(createInstanceSchema), + async (req: Request, res: Response) => { + const instance = await instancesService.createInstance(req.body, req.user!.id, req.ip); + res.status(201).json({ data: instance }); + } +); + +router.put( + '/:id', + requireRole('SUPER_ADMIN', 'OPERATOR'), + validate(updateInstanceSchema), + async (req: Request, res: Response) => { + const instance = await instancesService.updateInstance( + req.params.id as string, + req.body, + req.user!.id, + req.ip + ); + res.json({ data: instance }); + } +); + +router.delete( + '/:id', + requireRole('SUPER_ADMIN'), + async (req: Request, res: Response) => { + const result = await instancesService.deleteInstance(req.params.id as string, req.user!.id, req.ip); + res.json(result); + } +); + +// Get decrypted secrets (SUPER_ADMIN only, rate limited) +router.get( + '/:id/secrets', + secretsLimiter, + requireRole('SUPER_ADMIN'), + async (req: Request, res: Response) => { + const secrets = await instancesService.getInstanceSecrets(req.params.id as string); + + // Audit log: someone viewed secrets + await prisma.auditLog.create({ + data: { + userId: req.user!.id, + instanceId: req.params.id as string, + action: AuditAction.INSTANCE_UPDATE, + details: { type: 'secrets_viewed' }, + ipAddress: req.ip, + }, + }); + + res.json({ data: secrets }); + } +); + +// ─── Reconfiguration ──────────────────────────────────────────────── + +router.post( + '/:id/reconfigure', + requireRole('SUPER_ADMIN', 'OPERATOR'), + validate(reconfigureInstanceSchema), + async (req: Request, res: Response) => { + const result = await instancesService.reconfigureInstance( + req.params.id as string, + req.body, + req.user!.id, + req.ip + ); + res.json({ data: result }); + } +); + +// ─── Lifecycle Endpoints ───────────────────────────────────────────── + +router.post( + '/:id/provision', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const result = await instancesService.provisionInstance(req.params.id as string, req.user!.id, req.ip); + res.json(result); + } +); + +router.post( + '/:id/start', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const result = await instancesService.startInstance(req.params.id as string, req.user!.id, req.ip); + res.json(result); + } +); + +router.post( + '/:id/stop', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const result = await instancesService.stopInstance(req.params.id as string, req.user!.id, req.ip); + res.json(result); + } +); + +router.post( + '/:id/restart', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const service = req.query.service as string | undefined; + const result = await instancesService.restartInstance( + req.params.id as string, + req.user!.id, + req.ip, + service + ); + res.json(result); + } +); + +// ─── Services & Logs ───────────────────────────────────────────────── + +router.get( + '/:id/services', + async (req: Request, res: Response) => { + const services = await instancesService.getInstanceServices(req.params.id as string); + res.json({ data: services }); + } +); + +router.get( + '/:id/logs', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const { service, tail, since } = req.query; + const tailNum = tail ? Math.min(Math.max(parseInt(tail as string, 10) || 200, 1), 2000) : 200; + const logs = await instancesService.getInstanceLogs( + req.params.id as string, + service as string | undefined, + tailNum, + since as string | undefined + ); + res.json({ data: logs }); + } +); + +// ─── Health Checks ────────────────────────────────────────────────── + +router.post( + '/:id/health-check', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const check = await healthService.checkInstanceHealth(req.params.id as string); + res.json({ data: check }); + } +); + +router.get( + '/:id/health-history', + async (req: Request, res: Response) => { + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); + const result = await healthService.getHealthHistory(req.params.id as string, page, limit); + res.json(result); + } +); + +// ─── Backups ──────────────────────────────────────────────────────── + +router.post( + '/:id/backup', + requireRole('SUPER_ADMIN', 'OPERATOR'), + async (req: Request, res: Response) => { + const backup = await backupService.createBackup(req.params.id as string, req.user!.id, req.ip); + res.status(201).json({ data: backup }); + } +); + +router.get( + '/:id/backups', + async (req: Request, res: Response) => { + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50)); + const result = await backupService.listBackups(req.params.id as string, page, limit); + res.json(result); + } +); + +export default router; diff --git a/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts b/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts new file mode 100644 index 00000000..3d417b40 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +export const createInstanceSchema = z.object({ + name: z.string().min(2).max(100), + slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'), + domain: z.string().min(3).max(255), + adminEmail: z.string().email(), + enableMedia: z.boolean().default(false), + enableChat: z.boolean().default(false), + enableGancio: z.boolean().default(false), + enableListmonk: z.boolean().default(false), + enableMonitoring: z.boolean().default(false), + enableDevTools: z.boolean().default(false), + enablePayments: z.boolean().default(false), + smtpHost: z.string().optional(), + smtpPort: z.coerce.number().optional(), + smtpUser: z.string().optional(), + smtpFrom: z.string().optional(), + emailTestMode: z.boolean().default(true), + enablePangolin: z.boolean().default(false), + notes: z.string().optional(), +}); + +export const updateInstanceSchema = z.object({ + name: z.string().min(2).max(100).optional(), + enableMedia: z.boolean().optional(), + enableChat: z.boolean().optional(), + enableGancio: z.boolean().optional(), + enableListmonk: z.boolean().optional(), + enableMonitoring: z.boolean().optional(), + enableDevTools: z.boolean().optional(), + enablePayments: z.boolean().optional(), + smtpHost: z.string().optional(), + smtpPort: z.coerce.number().optional(), + smtpUser: z.string().optional(), + smtpFrom: z.string().optional(), + emailTestMode: z.boolean().optional(), + notes: z.string().nullable().optional(), +}); + +export const registerInstanceSchema = z.object({ + name: z.string().min(2).max(100), + slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'), + domain: z.string().min(3).max(255), + basePath: z.string().min(1), + composeProject: z.string().min(1), + portConfig: z.object({ + api: z.coerce.number().int().min(1).max(65535), + admin: z.coerce.number().int().min(1).max(65535), + postgres: z.coerce.number().int().min(1).max(65535), + nginx: z.coerce.number().int().min(1).max(65535), + }), + adminEmail: z.string().email().optional().default('admin@localhost'), + enableMedia: z.boolean().default(false), + enableChat: z.boolean().default(false), + enableGancio: z.boolean().default(false), + enableListmonk: z.boolean().default(false), + enableMonitoring: z.boolean().default(false), + enableDevTools: z.boolean().default(false), + enablePayments: z.boolean().default(false), + notes: z.string().optional(), +}); + +export const reconfigureInstanceSchema = z.object({ + enableMedia: z.boolean().optional(), + enableChat: z.boolean().optional(), + enableGancio: z.boolean().optional(), + enableListmonk: z.boolean().optional(), + enableMonitoring: z.boolean().optional(), + enableDevTools: z.boolean().optional(), + enablePayments: z.boolean().optional(), +}); + +export const importInstancesSchema = z.object({ + instances: z.array(registerInstanceSchema).min(1).max(50), +}); + +export type CreateInstanceInput = z.infer; +export type UpdateInstanceInput = z.infer; +export type RegisterInstanceInput = z.infer; +export type ReconfigureInstanceInput = z.infer; +export type ImportInstancesInput = z.infer; diff --git a/changemaker-control-panel/api/src/modules/instances/instances.service.ts b/changemaker-control-panel/api/src/modules/instances/instances.service.ts new file mode 100644 index 00000000..c3331fe1 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/instances/instances.service.ts @@ -0,0 +1,607 @@ +import { Prisma, InstanceStatus, AuditAction } from '@prisma/client'; +import fs from 'fs/promises'; +import { parse as parseDotenv } from 'dotenv'; +import { prisma } from '../../lib/prisma'; +import { env } from '../../config/env'; +import { AppError } from '../../middleware/error-handler'; +import { encryptJson, decryptJson } from '../../utils/encryption'; +import { generateSecrets } from '../../services/secret-generator'; +import { allocatePorts, releasePorts } from '../../services/port-allocator'; +import * as docker from '../../services/docker.service'; +import { provision } from './provisioner'; +import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput } from './instances.schemas'; +import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine'; +import { logger } from '../../utils/logger'; +import path from 'path'; + +// ─── CRUD Operations ───────────────────────────────────────────────── + +export async function listInstances() { + return prisma.instance.findMany({ + orderBy: { createdAt: 'desc' }, + omit: { encryptedSecrets: true }, + include: { + portAllocations: true, + _count: { select: { healthChecks: true, backups: true } }, + }, + }); +} + +export async function getInstance(id: string) { + const instance = await prisma.instance.findUnique({ + where: { id }, + omit: { encryptedSecrets: true }, + include: { + portAllocations: true, + healthChecks: { orderBy: { checkedAt: 'desc' }, take: 10 }, + backups: { orderBy: { startedAt: 'desc' }, take: 10 }, + }, + }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + return instance; +} + +export async function createInstance(input: CreateInstanceInput, userId: string, ipAddress?: string) { + // Check uniqueness + const existing = await prisma.instance.findFirst({ + where: { OR: [{ slug: input.slug }, { domain: input.domain }] }, + }); + if (existing) { + const field = existing.slug === input.slug ? 'slug' : 'domain'; + throw new AppError(409, `Instance with this ${field} already exists`, 'DUPLICATE'); + } + + // Allocate ports + const ports = await allocatePorts(); + + // Generate secrets + const secrets = generateSecrets(input.adminEmail); + + // Compute paths + const composeProject = `cml-${input.slug}`; + const basePath = path.join(env.INSTANCES_BASE_PATH, input.slug, 'changemaker.lite'); + + // Create instance record + const instance = await prisma.instance.create({ + data: { + slug: input.slug, + name: input.name, + domain: input.domain, + status: InstanceStatus.PROVISIONING, + basePath, + composeProject, + gitBranch: env.CML_GIT_BRANCH, + portConfig: ports.config, + encryptedSecrets: encryptJson(secrets as unknown as Record), + enableMedia: input.enableMedia, + enableChat: input.enableChat, + enableGancio: input.enableGancio, + enableListmonk: input.enableListmonk, + enableMonitoring: input.enableMonitoring, + enableDevTools: input.enableDevTools, + enablePayments: input.enablePayments, + adminEmail: input.adminEmail, + smtpHost: input.smtpHost, + smtpPort: input.smtpPort, + smtpUser: input.smtpUser, + smtpFrom: input.smtpFrom, + emailTestMode: input.emailTestMode, + notes: input.notes, + portAllocations: { + create: ports.allocations, + }, + }, + include: { portAllocations: true }, + }); + + // Audit log + await prisma.auditLog.create({ + data: { + userId, + instanceId: instance.id, + action: AuditAction.INSTANCE_CREATE, + details: { name: input.name, domain: input.domain, slug: input.slug }, + ipAddress, + }, + }); + + // Kick off provisioning asynchronously (fire-and-forget) + provision(instance.id).catch((err) => { + logger.error(`[instances] Provisioning failed for ${instance.slug}: ${err}`); + }); + + return instance; +} + +export async function registerInstance(input: RegisterInstanceInput, userId: string, ipAddress?: string) { + // Check uniqueness (slug, domain, composeProject) + const existing = await prisma.instance.findFirst({ + where: { + OR: [ + { slug: input.slug }, + { domain: input.domain }, + { composeProject: input.composeProject }, + ], + }, + }); + if (existing) { + const field = existing.slug === input.slug ? 'slug' + : existing.domain === input.domain ? 'domain' + : 'composeProject'; + throw new AppError(409, `Instance with this ${field} already exists`, 'DUPLICATE'); + } + + // Verify basePath has a docker-compose.yml + try { + await fs.access(path.join(input.basePath, 'docker-compose.yml')); + } catch { + throw new AppError(400, `No docker-compose.yml found at ${input.basePath}`, 'INVALID_PATH'); + } + + // Detect running containers to determine initial status + let initialStatus: InstanceStatus = InstanceStatus.STOPPED; + try { + const containers = await docker.composePs(input.basePath, input.composeProject); + const runningCount = containers.filter((c) => c.state === 'running').length; + if (runningCount > 0) { + initialStatus = InstanceStatus.RUNNING; + } + } catch { + logger.warn(`[instances] Could not detect containers for ${input.composeProject}, defaulting to STOPPED`); + } + + // Create instance record + const instance = await prisma.instance.create({ + data: { + slug: input.slug, + name: input.name, + domain: input.domain, + status: initialStatus, + statusMessage: initialStatus === InstanceStatus.RUNNING ? 'Registered — containers running' : 'Registered — containers not running', + basePath: input.basePath, + composeProject: input.composeProject, + portConfig: input.portConfig, + encryptedSecrets: null, + isRegistered: true, + enableMedia: input.enableMedia, + enableChat: input.enableChat, + enableGancio: input.enableGancio, + enableListmonk: input.enableListmonk, + enableMonitoring: input.enableMonitoring, + enableDevTools: input.enableDevTools, + enablePayments: input.enablePayments, + adminEmail: input.adminEmail, + notes: input.notes, + }, + }); + + // Create PortAllocation records (try-catch each for unique constraint) + for (const [service, port] of Object.entries(input.portConfig)) { + try { + await prisma.portAllocation.create({ + data: { port, service, instanceId: instance.id }, + }); + } catch (err) { + logger.warn(`[instances] Port ${port} (${service}) already allocated, skipping`); + } + } + + // Audit log + await prisma.auditLog.create({ + data: { + userId, + instanceId: instance.id, + action: AuditAction.INSTANCE_CREATE, + details: { name: input.name, domain: input.domain, slug: input.slug, registered: true }, + ipAddress, + }, + }); + + // Trigger immediate health check if running + if (initialStatus === InstanceStatus.RUNNING) { + import('../../services/health.service').then((healthService) => { + healthService.checkInstanceHealth(instance.id).catch((err) => { + logger.warn(`[instances] Initial health check failed for ${instance.slug}: ${(err as Error).message}`); + }); + }); + } + + // Re-fetch with relations + return prisma.instance.findUnique({ + where: { id: instance.id }, + include: { portAllocations: true }, + }); +} + +export async function updateInstance(id: string, input: UpdateInstanceInput, userId: string, ipAddress?: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + const updated = await prisma.instance.update({ + where: { id }, + data: input, + }); + + await prisma.auditLog.create({ + data: { + userId, + instanceId: id, + action: AuditAction.INSTANCE_UPDATE, + details: input as unknown as Prisma.InputJsonValue, + ipAddress, + }, + }); + + return updated; +} + +export async function deleteInstance(id: string, userId: string, ipAddress?: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + // Registered instances: just remove from DB, never touch containers or files + if (instance.isRegistered) { + await releasePorts(id); + await prisma.instance.delete({ where: { id } }); + + await prisma.auditLog.create({ + data: { + userId, + action: AuditAction.INSTANCE_DELETE, + details: { name: instance.name, domain: instance.domain, slug: instance.slug, unregistered: true }, + ipAddress, + }, + }); + + return { message: 'Instance unregistered' }; + } + + // Mark as destroying + await prisma.instance.update({ + where: { id }, + data: { status: InstanceStatus.DESTROYING, statusMessage: 'Shutting down containers...' }, + }); + + // Stop containers and remove volumes + try { + await docker.composeDown(instance.basePath, instance.composeProject, true); + logger.info(`[instances] ${instance.slug}: Containers stopped and volumes removed`); + } catch (err) { + logger.warn(`[instances] ${instance.slug}: Docker cleanup warning: ${(err as Error).message}`); + // Continue with deletion even if docker cleanup partially fails + } + + // Delete instance directory (with safety check) + const instanceDir = path.resolve(path.dirname(instance.basePath)); + const expectedBase = path.resolve(env.INSTANCES_BASE_PATH); + if (instanceDir.startsWith(expectedBase + '/') && instanceDir !== expectedBase) { + try { + await fs.rm(instanceDir, { recursive: true, force: true }); + logger.info(`[instances] ${instance.slug}: Directory ${instanceDir} removed`); + } catch (err) { + logger.warn(`[instances] ${instance.slug}: Directory cleanup warning: ${(err as Error).message}`); + } + } else { + logger.error(`[instances] ${instance.slug}: Refusing to delete path outside base: ${instanceDir}`); + } + + // Release ports and delete instance from DB + await releasePorts(id); + await prisma.instance.delete({ where: { id } }); + + await prisma.auditLog.create({ + data: { + userId, + action: AuditAction.INSTANCE_DELETE, + details: { name: instance.name, domain: instance.domain, slug: instance.slug }, + ipAddress, + }, + }); + + return { message: 'Instance deleted' }; +} + +export async function getInstanceSecrets(id: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + // CCP-provisioned instance: decrypt from DB + if (!instance.isRegistered && instance.encryptedSecrets) { + return decryptJson(instance.encryptedSecrets); + } + + // Registered/discovered instance: read from .env on disk + // Path traversal protection: basePath must be within INSTANCES_BASE_PATH or CML_SOURCE_PATH + const resolvedBase = path.resolve(instance.basePath); + const allowedPaths = [ + path.resolve(env.INSTANCES_BASE_PATH), + ...(env.CML_SOURCE_PATH ? [path.resolve(env.CML_SOURCE_PATH)] : []), + ]; + const isAllowed = allowedPaths.some( + (allowed) => resolvedBase === allowed || resolvedBase.startsWith(allowed + '/') + ); + if (!isAllowed) { + throw new AppError(400, 'Instance path is outside the allowed directory', 'INVALID_PATH'); + } + + const envPath = path.join(resolvedBase, '.env'); + let envVars: Record | null = null; + try { + const content = await fs.readFile(envPath, 'utf-8'); + envVars = parseDotenv(Buffer.from(content)); + } catch { + envVars = null; + } + + if (!envVars) { + throw new AppError(400, 'Could not read .env file for this instance', 'ENV_NOT_FOUND'); + } + + return { + initialAdminEmail: envVars.INITIAL_ADMIN_EMAIL || instance.adminEmail, + initialAdminPassword: envVars.INITIAL_ADMIN_PASSWORD || null, + }; +} + +// ─── Lifecycle Operations ──────────────────────────────────────────── + +export async function provisionInstance(id: string, userId: string, ipAddress?: string) { + // Registered instances cannot be provisioned + const check = await prisma.instance.findUnique({ where: { id }, select: { isRegistered: true } }); + if (check?.isRegistered) { + throw new AppError(400, 'Cannot provision a registered instance', 'NOT_MANAGED'); + } + + // Atomic check-and-update to prevent concurrent provisioning + const { count } = await prisma.instance.updateMany({ + where: { id, status: { in: [InstanceStatus.ERROR, InstanceStatus.STOPPED] } }, + data: { status: InstanceStatus.PROVISIONING, statusMessage: 'Retrying provisioning...' }, + }); + + if (count === 0) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + throw new AppError(400, `Cannot provision instance in ${instance.status} state`, 'INVALID_STATE'); + } + + await prisma.auditLog.create({ + data: { + userId, + instanceId: id, + action: AuditAction.INSTANCE_UPDATE, + details: { event: 'provision_retry' }, + ipAddress, + }, + }); + + // Fire-and-forget + provision(id).catch((err) => { + logger.error(`[instances] Re-provisioning failed for ${id}: ${err}`); + }); + + return { message: 'Provisioning started' }; +} + +export async function startInstance(id: string, userId: string, ipAddress?: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + if (instance.status !== 'STOPPED' && instance.status !== 'ERROR') { + throw new AppError(400, `Cannot start instance in ${instance.status} state`, 'INVALID_STATE'); + } + + try { + await docker.composeUp(instance.basePath, instance.composeProject); + + await prisma.instance.update({ + where: { id }, + data: { status: InstanceStatus.RUNNING, statusMessage: 'All containers started' }, + }); + + await prisma.auditLog.create({ + data: { + userId, + instanceId: id, + action: AuditAction.INSTANCE_START, + details: { slug: instance.slug }, + ipAddress, + }, + }); + + return { message: 'Instance started' }; + } catch (err) { + const errorMsg = (err as Error).message; + await prisma.instance.update({ + where: { id }, + data: { status: InstanceStatus.ERROR, statusMessage: `Start failed: ${errorMsg}` }, + }); + throw new AppError(500, `Failed to start instance: ${errorMsg}`, 'DOCKER_ERROR'); + } +} + +export async function stopInstance(id: string, userId: string, ipAddress?: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + if (instance.status !== 'RUNNING' && instance.status !== 'ERROR') { + throw new AppError(400, `Cannot stop instance in ${instance.status} state`, 'INVALID_STATE'); + } + + try { + await docker.composeStop(instance.basePath, instance.composeProject); + + await prisma.instance.update({ + where: { id }, + data: { status: InstanceStatus.STOPPED, statusMessage: 'All containers stopped' }, + }); + + await prisma.auditLog.create({ + data: { + userId, + instanceId: id, + action: AuditAction.INSTANCE_STOP, + details: { slug: instance.slug }, + ipAddress, + }, + }); + + return { message: 'Instance stopped' }; + } catch (err) { + const errorMsg = (err as Error).message; + throw new AppError(500, `Failed to stop instance: ${errorMsg}`, 'DOCKER_ERROR'); + } +} + +export async function restartInstance(id: string, userId: string, ipAddress?: string, service?: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + try { + await docker.composeRestart(instance.basePath, instance.composeProject, service); + + await prisma.auditLog.create({ + data: { + userId, + instanceId: id, + action: AuditAction.INSTANCE_RESTART, + details: { slug: instance.slug, service: service || 'all' }, + ipAddress, + }, + }); + + return { message: `${service || 'All services'} restarted` }; + } catch (err) { + const errorMsg = (err as Error).message; + throw new AppError(500, `Failed to restart: ${errorMsg}`, 'DOCKER_ERROR'); + } +} + +export async function getInstanceServices(id: string) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + try { + return await docker.composePs(instance.basePath, instance.composeProject); + } catch { + // If compose ps fails (e.g. no containers), return empty array + return []; + } +} + +export async function getInstanceLogs( + id: string, + service?: string, + tail = 200, + since?: string +) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + try { + return await docker.composeLogs( + instance.basePath, + instance.composeProject, + service, + tail, + since + ); + } catch (err) { + throw new AppError(500, `Failed to get logs: ${(err as Error).message}`, 'DOCKER_ERROR'); + } +} + +// ─── Reconfiguration ───────────────────────────────────────────────── + +export async function reconfigureInstance( + id: string, + features: ReconfigureInstanceInput, + userId: string, + ipAddress?: string +) { + const instance = await prisma.instance.findUnique({ where: { id } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + if (instance.isRegistered) { + throw new AppError(400, 'Cannot reconfigure an external instance', 'NOT_MANAGED'); + } + if (!instance.encryptedSecrets) { + throw new AppError(400, 'Instance has no secrets — cannot reconfigure', 'NOT_MANAGED'); + } + if (instance.status !== 'RUNNING' && instance.status !== 'STOPPED') { + throw new AppError(400, `Cannot reconfigure instance in ${instance.status} state`, 'INVALID_STATE'); + } + + // Update feature flags in DB + const updated = await prisma.instance.update({ + where: { id }, + data: { + ...features, + statusMessage: 'Reconfiguring...', + }, + }); + + // Clear template cache so updated templates are re-read + clearTemplateCache(); + + // Re-render templates with updated flags + const secrets = decryptJson>(instance.encryptedSecrets); + const context = buildTemplateContext(updated, secrets); + await renderAllTemplates(context, instance.basePath); + + // If instance is running, apply changes via docker compose up + if (instance.status === 'RUNNING') { + try { + await docker.composeUp(instance.basePath, instance.composeProject); + // --remove-orphans (from composeUp) will clean up disabled services + + await prisma.instance.update({ + where: { id }, + data: { statusMessage: 'Reconfiguration complete' }, + }); + } catch (err) { + const errorMsg = (err as Error).message; + await prisma.instance.update({ + where: { id }, + data: { statusMessage: `Reconfiguration failed: ${errorMsg}` }, + }); + throw new AppError(500, `Reconfiguration failed: ${errorMsg}`, 'DOCKER_ERROR'); + } + } else { + await prisma.instance.update({ + where: { id }, + data: { statusMessage: 'Reconfiguration complete — start instance to apply' }, + }); + } + + // Audit log + await prisma.auditLog.create({ + data: { + userId, + instanceId: id, + action: AuditAction.INSTANCE_UPDATE, + details: { event: 'reconfigure', features } as unknown as Prisma.InputJsonValue, + ipAddress, + }, + }); + + return updated; +} diff --git a/changemaker-control-panel/api/src/modules/instances/provisioner.ts b/changemaker-control-panel/api/src/modules/instances/provisioner.ts new file mode 100644 index 00000000..75b742f1 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/instances/provisioner.ts @@ -0,0 +1,197 @@ +import { InstanceStatus, AuditAction } from '@prisma/client'; +import { exec as execCb } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs/promises'; +import path from 'path'; +import { prisma } from '../../lib/prisma'; +import { env } from '../../config/env'; +import { decryptJson } from '../../utils/encryption'; +import { renderAllTemplates, buildTemplateContext } from '../../services/template-engine'; +import * as docker from '../../services/docker.service'; +import { logger } from '../../utils/logger'; +const exec = promisify(execCb); + +/** + * Directories/files to exclude when copying CML source to instance directory. + */ +const COPY_EXCLUDES = [ + 'node_modules', + '.git', + '.env', + 'changemaker-control-panel', + '.claude', +]; + +/** + * Update instance status and statusMessage in the database. + */ +async function updateStatus( + instanceId: string, + status: InstanceStatus, + statusMessage: string +): Promise { + await prisma.instance.update({ + where: { id: instanceId }, + data: { status, statusMessage }, + }); +} + +/** + * Provision a CML instance: copy source, render configs, build and start Docker stack. + * + * This function runs asynchronously — call it without awaiting. + * Progress is tracked via instance.status and instance.statusMessage. + */ +export async function provision(instanceId: string): Promise { + const totalSteps = 13; + let step = 0; + + function stepMsg(description: string): string { + step++; + return `Step ${step}/${totalSteps}: ${description}`; + } + + try { + // Load instance with all details + const instance = await prisma.instance.findUnique({ + where: { id: instanceId }, + include: { portAllocations: true }, + }); + if (!instance) { + throw new Error(`Instance ${instanceId} not found`); + } + + const { basePath, composeProject } = instance; + const instanceDir = path.dirname(basePath); // parent of changemaker.lite + + // ── Step 1: Create instance directory ─────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Creating instance directory')); + logger.info(`[provisioner] ${instance.slug}: Creating directory ${basePath}`); + await fs.mkdir(basePath, { recursive: true }); + + // ── Step 2: Copy CML source ──────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Copying CML source')); + logger.info(`[provisioner] ${instance.slug}: Copying from ${env.CML_SOURCE_PATH} to ${basePath}`); + + const excludeFlags = COPY_EXCLUDES.map((e) => `--exclude='${e}'`).join(' '); + await exec( + `rsync -a ${excludeFlags} ${env.CML_SOURCE_PATH}/ ${basePath}/`, + { timeout: 120_000 } + ); + + // ── Step 2b: Create media directories ────────────────────────── + // Media API volume mounts use ./media as the read-only base with + // rw overlays for subdirectories. All must exist before Docker starts. + const mediaDirs = [ + 'media/local/inbox', + 'media/local/thumbnails', + 'media/local/photos', + 'media/public', + ]; + for (const dir of mediaDirs) { + await fs.mkdir(path.join(basePath, dir), { recursive: true }); + } + logger.info(`[provisioner] ${instance.slug}: Created media directories`); + + // ── Step 3: Decrypt secrets ──────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Preparing configuration')); + if (!instance.encryptedSecrets) { + throw new Error('Instance has no encrypted secrets — cannot provision'); + } + const secrets = decryptJson>(instance.encryptedSecrets); + + // ── Step 4: Build template context ───────────────────────────── + const context = buildTemplateContext(instance, secrets); + + // ── Step 5: Render templates ─────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Rendering configuration files')); + logger.info(`[provisioner] ${instance.slug}: Rendering templates to ${basePath}`); + await renderAllTemplates(context, basePath); + + // ── Step 6: Pull base images ─────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Pulling Docker images')); + logger.info(`[provisioner] ${instance.slug}: Pulling images`); + try { + await docker.composePull(basePath, composeProject); + } catch (err) { + // Pull failures are non-fatal if images already exist locally + logger.warn(`[provisioner] ${instance.slug}: Pull warning: ${(err as Error).message}`); + } + + // ── Step 7: Build custom images ──────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Building Docker images')); + logger.info(`[provisioner] ${instance.slug}: Building images`); + await docker.composeBuild(basePath, composeProject); + + // ── Step 8: Start infrastructure (Postgres + Redis) ──────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting database and cache')); + logger.info(`[provisioner] ${instance.slug}: Starting infrastructure services`); + await docker.composeUp(basePath, composeProject, ['v2-postgres', 'redis']); + + // ── Step 9: Wait for infrastructure healthy ──────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Waiting for database to be ready')); + logger.info(`[provisioner] ${instance.slug}: Waiting for Postgres + Redis healthy`); + // Container names match template's container_name: {{containerPrefix}}-postgres / {{containerPrefix}}-redis + const pgContainer = `${composeProject}-postgres`; + const redisContainer = `${composeProject}-redis`; + await Promise.all([ + docker.waitForHealthy(pgContainer, 60_000), + docker.waitForHealthy(redisContainer, 60_000), + ]); + + // ── Step 10: Run database schema sync ───────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Setting up database schema')); + logger.info(`[provisioner] ${instance.slug}: Pushing Prisma schema to database`); + // Use composeRun with --entrypoint "" to skip the API's startup entrypoint + // (which would try migrate deploy + seed and fail on schema drift) + await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db push --accept-data-loss', 180_000); + + // ── Step 11: Seed database ───────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Seeding database')); + logger.info(`[provisioner] ${instance.slug}: Seeding database`); + await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db seed', 120_000); + + // ── Step 12: Start all services ──────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting all services')); + logger.info(`[provisioner] ${instance.slug}: Starting all services`); + await docker.composeUp(basePath, composeProject); + + // ── Step 13: Health check ────────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Verifying instance health')); + logger.info(`[provisioner] ${instance.slug}: Waiting for API health check`); + const ports = instance.portConfig as Record; + // Use host.docker.internal to reach ports exposed on the Docker host + // (localhost inside the CCP container refers to the CCP container itself) + await docker.waitForHttp(`http://host.docker.internal:${ports.api}/api/health`, 120_000); + + // ── Done! ────────────────────────────────────────────────────── + await updateStatus(instanceId, InstanceStatus.RUNNING, 'Provisioning complete'); + logger.info(`[provisioner] ${instance.slug}: Provisioning complete!`); + + // Audit log + await prisma.auditLog.create({ + data: { + instanceId, + action: AuditAction.INSTANCE_CREATE, + details: { event: 'provisioning_complete', slug: instance.slug }, + }, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error(`[provisioner] ${instanceId}: Failed — ${errorMsg}`); + await updateStatus( + instanceId, + InstanceStatus.ERROR, + `Provisioning failed at ${step > 0 ? `step ${step}/${totalSteps}` : 'startup'}: ${errorMsg}` + ).catch(() => {}); + + // Audit log + await prisma.auditLog.create({ + data: { + instanceId, + action: AuditAction.INSTANCE_CREATE, + details: { event: 'provisioning_failed', error: errorMsg, step }, + }, + }).catch(() => {}); + } +} diff --git a/changemaker-control-panel/api/src/modules/settings/settings.routes.ts b/changemaker-control-panel/api/src/modules/settings/settings.routes.ts new file mode 100644 index 00000000..a30219f5 --- /dev/null +++ b/changemaker-control-panel/api/src/modules/settings/settings.routes.ts @@ -0,0 +1,43 @@ +import { Router, Request, Response } from 'express'; +import { AuditAction } from '@prisma/client'; +import { prisma } from '../../lib/prisma'; +import { authenticate, requireRole } from '../../middleware/auth'; +const router = Router(); + +router.use(authenticate); + +router.get('/', async (_req: Request, res: Response) => { + const settings = await prisma.ccpSetting.findMany(); + const map: Record = {}; + for (const s of settings) { + map[s.key] = s.value; + } + res.json({ data: map }); +}); + +router.put( + '/:key', + requireRole('SUPER_ADMIN'), + async (req: Request, res: Response) => { + const { key } = req.params as { key: string }; + const { value } = req.body; + const setting = await prisma.ccpSetting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + + await prisma.auditLog.create({ + data: { + userId: req.user!.id, + action: AuditAction.SETTINGS_UPDATE, + details: { key, value }, + ipAddress: req.ip, + }, + }); + + res.json({ data: setting }); + } +); + +export default router; diff --git a/changemaker-control-panel/api/src/server.ts b/changemaker-control-panel/api/src/server.ts new file mode 100644 index 00000000..caa2a693 --- /dev/null +++ b/changemaker-control-panel/api/src/server.ts @@ -0,0 +1,66 @@ +import 'express-async-errors'; +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import compression from 'compression'; +import rateLimit from 'express-rate-limit'; +import { env } from './config/env'; +import { logger } from './utils/logger'; +import { errorHandler } from './middleware/error-handler'; + +// Route imports +import authRoutes from './modules/auth/auth.routes'; +import instanceRoutes from './modules/instances/instances.routes'; +import settingsRoutes from './modules/settings/settings.routes'; +import healthRoutes from './modules/health/health.routes'; +import auditRoutes from './modules/audit/audit.routes'; +import backupRoutes from './modules/backups/backup.routes'; +import { startHealthScheduler } from './services/health.service'; +import { autoDiscoverOnStartup } from './services/discovery.service'; + +const app = express(); + +// Global middleware +app.use(helmet()); +app.use(compression()); +app.use(express.json({ limit: '10mb' })); +app.use( + cors({ + origin: env.CORS_ORIGINS.split(',').map((s) => s.trim()), + credentials: true, + }) +); + +// Rate limiters +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 15, // 15 attempts per window + standardHeaders: true, + legacyHeaders: false, + message: { error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } }, +}); + +// Routes +app.use('/api/auth', authLimiter, authRoutes); +app.use('/api/instances', instanceRoutes); +app.use('/api/settings', settingsRoutes); +app.use('/api/health', healthRoutes); +app.use('/api/audit', auditRoutes); +app.use('/api/backups', backupRoutes); + +// Error handler (must be last) +app.use(errorHandler); + +app.listen(env.PORT, () => { + logger.info(`CCP API listening on port ${env.PORT} (${env.NODE_ENV})`); + startHealthScheduler(env.HEALTH_CHECK_INTERVAL_MS); + + // Auto-discover parent CML instance on first boot (5s delay for DB readiness) + setTimeout(() => { + autoDiscoverOnStartup().catch((err) => + logger.error(`[discovery] Auto-discovery failed: ${(err as Error).message}`) + ); + }, 5_000); +}); + +export default app; diff --git a/changemaker-control-panel/api/src/services/backup.service.ts b/changemaker-control-panel/api/src/services/backup.service.ts new file mode 100644 index 00000000..c72d222b --- /dev/null +++ b/changemaker-control-panel/api/src/services/backup.service.ts @@ -0,0 +1,313 @@ +import { Prisma, BackupStatus, AuditAction, InstanceStatus } from '@prisma/client'; +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; +import { exec as execCb } from 'child_process'; +import { promisify } from 'util'; +import { prisma } from '../lib/prisma'; +import { env } from '../config/env'; +import { AppError } from '../middleware/error-handler'; +import { decryptJson } from '../utils/encryption'; +import * as docker from './docker.service'; +import { logger } from '../utils/logger'; +const exec = promisify(execCb); + +/** + * Compute SHA-256 hash of a file. + */ +async function fileHash(filePath: string): Promise { + const fileBuffer = await fs.readFile(filePath); + return crypto.createHash('sha256').update(fileBuffer).digest('hex'); +} + +/** + * Get file size in bytes. + */ +async function fileSize(filePath: string): Promise { + const stat = await fs.stat(filePath); + return stat.size; +} + +/** + * Create a backup for a given instance. + */ +export async function createBackup(instanceId: string, userId?: string, ipAddress?: string) { + const instance = await prisma.instance.findUnique({ where: { id: instanceId } }); + if (!instance) { + throw new AppError(404, 'Instance not found', 'NOT_FOUND'); + } + + if (instance.status !== InstanceStatus.RUNNING) { + throw new AppError(400, `Cannot backup instance in ${instance.status} state`, 'INVALID_STATE'); + } + + if ((instance as { isRegistered?: boolean }).isRegistered) { + throw new AppError(400, 'Backups not managed by CCP for registered instances', 'NOT_MANAGED'); + } + + // Create backup record + const backup = await prisma.backup.create({ + data: { + instanceId, + status: BackupStatus.PENDING, + }, + }); + + // Run backup asynchronously + performBackup(backup.id, instance, userId, ipAddress).catch((err) => { + logger.error(`[backup] Backup ${backup.id} failed: ${(err as Error).message}`); + }); + + return backup; +} + +async function performBackup( + backupId: string, + instance: { id: string; slug: string; basePath: string; composeProject: string; encryptedSecrets: string | null }, + userId?: string, + ipAddress?: string +) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupDir = path.join(env.BACKUP_STORAGE_PATH, instance.slug, timestamp); + + try { + // Update status to IN_PROGRESS + await prisma.backup.update({ + where: { id: backupId }, + data: { status: BackupStatus.IN_PROGRESS }, + }); + + // Ensure backup directory exists + await fs.mkdir(backupDir, { recursive: true }); + + const manifestFiles: Array<{ name: string; size: number; sha256: string }> = []; + + // 1. Dump PostgreSQL + try { + const secrets = instance.encryptedSecrets + ? decryptJson>(instance.encryptedSecrets) + : {} as Record; + const pgPassword = secrets.V2_POSTGRES_PASSWORD || secrets.postgresPassword || 'changemaker'; + + // Use docker compose exec to run pg_dump inside the container + // Pass PGPASSWORD via -e flag so pg_dump can authenticate + const dumpOutput = await docker.composeExec( + instance.basePath, + instance.composeProject, + 'v2-postgres', + `pg_dump -U changemaker -d changemaker`, + 300_000, // 5 min timeout for large DBs + { PGPASSWORD: pgPassword } + ); + + const dumpPath = path.join(backupDir, 'v2-postgres.sql'); + await fs.writeFile(dumpPath, dumpOutput); + + // Gzip the dump + await exec(`gzip "${dumpPath}"`, { timeout: 120_000 }); + const gzPath = dumpPath + '.gz'; + + manifestFiles.push({ + name: 'v2-postgres.sql.gz', + size: await fileSize(gzPath), + sha256: await fileHash(gzPath), + }); + + logger.info(`[backup] ${instance.slug}: PostgreSQL dump complete`); + } catch (err) { + logger.warn(`[backup] ${instance.slug}: PostgreSQL dump failed: ${(err as Error).message}`); + // Continue with backup — mark the dump as failed in manifest + } + + // 2. Archive uploads if they exist + const uploadsDir = path.join(instance.basePath, 'uploads'); + try { + await fs.access(uploadsDir); + const uploadsArchive = path.join(backupDir, 'uploads.tar.gz'); + await exec(`tar -czf "${uploadsArchive}" -C "${instance.basePath}" uploads`, { timeout: 300_000 }); + + manifestFiles.push({ + name: 'uploads.tar.gz', + size: await fileSize(uploadsArchive), + sha256: await fileHash(uploadsArchive), + }); + + logger.info(`[backup] ${instance.slug}: Uploads archive complete`); + } catch { + // No uploads directory or archive failed — skip + logger.debug(`[backup] ${instance.slug}: No uploads directory or archive skipped`); + } + + // 3. Generate manifest + const manifest = { + instanceId: instance.id, + instanceSlug: instance.slug, + timestamp: new Date().toISOString(), + files: manifestFiles, + }; + + const manifestPath = path.join(backupDir, 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + + // 4. Create final archive + const archiveName = `backup-${instance.slug}-${timestamp}.tar.gz`; + const archivePath = path.join(env.BACKUP_STORAGE_PATH, instance.slug, archiveName); + + await exec(`tar -czf "${archivePath}" -C "${path.dirname(backupDir)}" "${path.basename(backupDir)}"`, { + timeout: 300_000, + }); + + const totalSize = await fileSize(archivePath); + + // Cleanup the temp directory + await fs.rm(backupDir, { recursive: true, force: true }); + + // Update backup record + await prisma.backup.update({ + where: { id: backupId }, + data: { + status: BackupStatus.COMPLETED, + archivePath, + sizeBytes: BigInt(totalSize), + manifest: manifest as unknown as Prisma.InputJsonValue, + completedAt: new Date(), + }, + }); + + // Audit log + if (userId) { + await prisma.auditLog.create({ + data: { + userId, + instanceId: instance.id, + action: AuditAction.BACKUP_CREATE, + details: { backupId, archiveName, sizeBytes: totalSize }, + ipAddress, + }, + }); + } + + logger.info(`[backup] ${instance.slug}: Backup complete (${(totalSize / 1024 / 1024).toFixed(1)} MB)`); + } catch (err) { + // Update backup as failed + await prisma.backup.update({ + where: { id: backupId }, + data: { + status: BackupStatus.FAILED, + errorMessage: (err as Error).message, + completedAt: new Date(), + }, + }); + + // Cleanup temp directory on failure + try { + await fs.rm(backupDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + + throw err; + } +} + +/** + * Delete a backup (file + DB record). + */ +export async function deleteBackup(backupId: string, userId?: string, ipAddress?: string) { + const backup = await prisma.backup.findUnique({ + where: { id: backupId }, + include: { instance: { select: { id: true, slug: true } } }, + }); + if (!backup) { + throw new AppError(404, 'Backup not found', 'NOT_FOUND'); + } + + // Delete archive file + if (backup.archivePath) { + try { + await fs.unlink(backup.archivePath); + } catch { + logger.warn(`[backup] Could not delete file: ${backup.archivePath}`); + } + } + + await prisma.backup.delete({ where: { id: backupId } }); + + if (userId) { + await prisma.auditLog.create({ + data: { + userId, + instanceId: backup.instanceId, + action: AuditAction.BACKUP_DELETE, + details: { backupId, instanceSlug: backup.instance?.slug }, + ipAddress, + }, + }); + } +} + +/** + * List backups with optional instance filter and pagination. + */ +export async function listBackups(instanceId?: string, page = 1, limit = 50) { + const where = instanceId ? { instanceId } : {}; + + const [data, total] = await Promise.all([ + prisma.backup.findMany({ + where, + orderBy: { startedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { + instance: { select: { id: true, name: true, slug: true } }, + }, + }), + prisma.backup.count({ where }), + ]); + + return { data, total, page, limit }; +} + +/** + * Get a single backup by ID. + */ +export async function getBackup(backupId: string) { + const backup = await prisma.backup.findUnique({ where: { id: backupId } }); + if (!backup) { + throw new AppError(404, 'Backup not found', 'NOT_FOUND'); + } + return backup; +} + +/** + * Cleanup backups older than retention period. + */ +export async function cleanupOldBackups(retentionDays: number): Promise { + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); + + const oldBackups = await prisma.backup.findMany({ + where: { + startedAt: { lt: cutoff }, + status: { in: [BackupStatus.COMPLETED, BackupStatus.FAILED] }, + }, + }); + + let deleted = 0; + for (const backup of oldBackups) { + try { + if (backup.archivePath) { + await fs.unlink(backup.archivePath); + } + await prisma.backup.delete({ where: { id: backup.id } }); + deleted++; + } catch (err) { + logger.warn(`[backup] Failed to cleanup backup ${backup.id}: ${(err as Error).message}`); + } + } + + if (deleted > 0) { + logger.info(`[backup] Cleaned up ${deleted} old backups (>${retentionDays} days)`); + } + + return deleted; +} diff --git a/changemaker-control-panel/api/src/services/discovery.service.ts b/changemaker-control-panel/api/src/services/discovery.service.ts new file mode 100644 index 00000000..66af508e --- /dev/null +++ b/changemaker-control-panel/api/src/services/discovery.service.ts @@ -0,0 +1,388 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { exec as execCb } from 'child_process'; +import { promisify } from 'util'; +import { parse as parseDotenv } from 'dotenv'; +import { prisma } from '../lib/prisma'; +import { env } from '../config/env'; +import { logger } from '../utils/logger'; +import { registerInstance } from '../modules/instances/instances.service'; +import * as docker from './docker.service'; + +const exec = promisify(execCb); + +// ─── Types ────────────────────────────────────────────────────────── + +export interface DiscoveredInstance { + name: string; + slug: string; + domain: string; + basePath: string; + composeProject: string; + portConfig: { api: number; admin: number; postgres: number; nginx: number }; + adminEmail: string; + enableMedia: boolean; + enableChat: boolean; + enableGancio: boolean; + enableListmonk: boolean; + enableMonitoring: boolean; + enableDevTools: boolean; + enablePayments: boolean; + emailTestMode: boolean; + // Discovery metadata (UI-only, not persisted) + source: 'parent' | 'docker'; + isRunning: boolean; + runningContainers: number; + totalContainers: number; + isAlreadyRegistered: boolean; + existingInstanceId?: string; + isParentInstance: boolean; +} + +export interface DiscoverySummary { + total: number; + newInstances: number; + alreadyRegistered: number; + running: number; + parentFound: boolean; +} + +export interface DiscoveryResult { + instances: DiscoveredInstance[]; + summary: DiscoverySummary; +} + +interface ComposeProject { + Name: string; + Status: string; + ConfigFiles: string; +} + +// ─── .env Parser ──────────────────────────────────────────────────── + +/** + * Parse a CML instance's .env file and extract safe configuration metadata. + * Never reads secrets (JWT keys, passwords, encryption keys). + */ +async function parseCmlEnv(envPath: string): Promise | null> { + try { + const content = await fs.readFile(envPath, 'utf-8'); + return parseDotenv(Buffer.from(content)); + } catch { + return null; + } +} + +function extractPortConfig(envVars: Record): DiscoveredInstance['portConfig'] { + return { + api: parseInt(envVars.API_PORT || '4000', 10), + admin: parseInt(envVars.ADMIN_PORT || '3000', 10), + postgres: parseInt(envVars.V2_POSTGRES_PORT || '5433', 10), + nginx: parseInt(envVars.NGINX_HTTP_PORT || '80', 10), + }; +} + +function extractFeatureFlags(envVars: Record) { + const isTrue = (val?: string) => val?.toLowerCase() === 'true'; + return { + enableMedia: isTrue(envVars.ENABLE_MEDIA_FEATURES), + enableChat: isTrue(envVars.ENABLE_CHAT), + enableGancio: isTrue(envVars.GANCIO_SYNC_ENABLED), + enableListmonk: isTrue(envVars.LISTMONK_SYNC_ENABLED), + enablePayments: isTrue(envVars.ENABLE_PAYMENTS), + emailTestMode: isTrue(envVars.EMAIL_TEST_MODE), + }; +} + +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); +} + +// ─── CML Fingerprint Test ─────────────────────────────────────────── + +/** + * Check if a directory is a CML instance by verifying key files exist + * and the .env has CML-specific variables. + */ +async function isCmlInstance(dirPath: string): Promise { + try { + // Must have docker-compose.yml and api/prisma/schema.prisma + await fs.access(path.join(dirPath, 'docker-compose.yml')); + await fs.access(path.join(dirPath, 'api', 'prisma', 'schema.prisma')); + + // Must have .env with DOMAIN and JWT_ACCESS_SECRET + const envVars = await parseCmlEnv(path.join(dirPath, '.env')); + if (!envVars) return false; + return !!(envVars.DOMAIN && envVars.JWT_ACCESS_SECRET); + } catch { + return false; + } +} + +// ─── Container Status ─────────────────────────────────────────────── + +async function getContainerCounts( + projectDir: string, + composeProject: string +): Promise<{ running: number; total: number }> { + try { + // Use docker.composePs which validates the project name + const containers = await docker.composePs(projectDir, composeProject); + const running = containers.filter((c) => c.state === 'running').length; + return { running, total: containers.length }; + } catch { + return { running: 0, total: 0 }; + } +} + +// ─── Compose Project Discovery ────────────────────────────────────── + +/** + * List all Docker Compose projects on the host via `docker compose ls`. + */ +async function listComposeProjects(): Promise { + try { + const { stdout } = await exec('docker compose ls --format json', { + timeout: 15_000, + env: { ...process.env, COMPOSE_ANSI: 'never' }, + }); + if (!stdout.trim()) return []; + return JSON.parse(stdout); + } catch (err) { + logger.warn(`[discovery] Failed to list compose projects: ${(err as Error).message}`); + return []; + } +} + +/** + * Derive the compose project name from a directory path. + * Docker Compose defaults to the directory basename (lowercased, special chars replaced). + */ +function deriveComposeProject(dirPath: string, projects: ComposeProject[]): string { + // Try to find project by matching config file path + const composePath = path.join(dirPath, 'docker-compose.yml'); + for (const p of projects) { + // ConfigFiles can be comma-separated + const configs = p.ConfigFiles.split(',').map((s) => s.trim()); + if (configs.some((c) => path.resolve(c) === path.resolve(composePath))) { + return p.Name; + } + } + // Fall back to directory basename convention + return path.basename(dirPath).toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +// ─── Build Discovered Instance ────────────────────────────────────── + +async function buildDiscoveredInstance( + dirPath: string, + source: 'parent' | 'docker', + composeProject: string, + existingInstances: Array<{ id: string; basePath: string; composeProject: string; domain: string }> +): Promise { + const envVars = await parseCmlEnv(path.join(dirPath, '.env')); + if (!envVars) return null; + + const domain = envVars.DOMAIN || path.basename(dirPath); + const portConfig = extractPortConfig(envVars); + const flags = extractFeatureFlags(envVars); + + // Check container status + const { running, total } = await getContainerCounts(dirPath, composeProject); + + // Infer monitoring/devtools from running containers (these aren't .env flags) + let enableMonitoring = false; + let enableDevTools = false; + try { + // Use docker.composePs which validates the project name (prevents shell injection) + const containers = await docker.composePs(dirPath, composeProject); + const serviceNames = containers.map((c) => c.service.toLowerCase()); + enableMonitoring = serviceNames.some((n) => n.includes('prometheus') || n.includes('grafana')); + enableDevTools = serviceNames.some((n) => n.includes('code-server') || n.includes('gitea') || n.includes('n8n')); + } catch { + // Couldn't inspect services — leave as false + } + + // Check deduplication against existing instances + const resolvedPath = path.resolve(dirPath); + const match = existingInstances.find( + (inst) => + path.resolve(inst.basePath) === resolvedPath || + inst.composeProject === composeProject || + inst.domain === domain + ); + + const isParent = source === 'parent' || + (!!env.CML_SOURCE_PATH && path.resolve(env.CML_SOURCE_PATH) === resolvedPath); + + return { + name: envVars.SITE_NAME || domain.split('.')[0] || path.basename(dirPath), + slug: slugify(envVars.SITE_NAME || domain.split('.')[0] || path.basename(dirPath)), + domain, + basePath: resolvedPath, + composeProject, + portConfig, + adminEmail: envVars.INITIAL_ADMIN_EMAIL || 'admin@localhost', + ...flags, + enableMonitoring, + enableDevTools, + source, + isRunning: running > 0, + runningContainers: running, + totalContainers: total, + isAlreadyRegistered: !!match, + existingInstanceId: match?.id, + isParentInstance: isParent, + }; +} + +// ─── Main Discovery Function ──────────────────────────────────────── + +export async function discoverInstances(): Promise { + const discovered: DiscoveredInstance[] = []; + const seenPaths = new Set(); + + // Load existing instances for deduplication + const existingInstances = await prisma.instance.findMany({ + select: { id: true, basePath: true, composeProject: true, domain: true }, + }); + + // List all compose projects upfront + const composeProjects = await listComposeProjects(); + + // Strategy 1: Parent instance (from CML_SOURCE_PATH) + if (env.CML_SOURCE_PATH) { + const parentPath = path.resolve(env.CML_SOURCE_PATH); + if (await isCmlInstance(parentPath)) { + const project = deriveComposeProject(parentPath, composeProjects); + const inst = await buildDiscoveredInstance(parentPath, 'parent', project, existingInstances); + if (inst) { + // Ensure parent gets a sensible name + if (inst.isParentInstance && inst.name === path.basename(parentPath)) { + inst.name = `Parent (${inst.domain})`; + } + discovered.push(inst); + seenPaths.add(parentPath); + } + } else { + logger.debug(`[discovery] CML_SOURCE_PATH (${env.CML_SOURCE_PATH}) is not a valid CML instance`); + } + } + + // Strategy 2: Docker scan — check all running compose projects + for (const project of composeProjects) { + // Skip CCP's own project + if (project.Name.startsWith('ccp-') || project.Name.startsWith('changemaker-control-panel')) { + continue; + } + + // Extract project directory from config file path + if (!project.ConfigFiles) continue; + const configFile = project.ConfigFiles.split(',')[0].trim(); + const projectDir = path.dirname(configFile); + const resolvedDir = path.resolve(projectDir); + + // Skip if already found via parent strategy + if (seenPaths.has(resolvedDir)) continue; + + // CML fingerprint test + if (!(await isCmlInstance(resolvedDir))) continue; + + const inst = await buildDiscoveredInstance(resolvedDir, 'docker', project.Name, existingInstances); + if (inst) { + discovered.push(inst); + seenPaths.add(resolvedDir); + } + } + + // Build summary + const newInstances = discovered.filter((d) => !d.isAlreadyRegistered).length; + const alreadyRegistered = discovered.filter((d) => d.isAlreadyRegistered).length; + const running = discovered.filter((d) => d.isRunning).length; + const parentFound = discovered.some((d) => d.isParentInstance); + + return { + instances: discovered, + summary: { + total: discovered.length, + newInstances, + alreadyRegistered, + running, + parentFound, + }, + }; +} + +// ─── Auto-Import on First Boot ────────────────────────────────────── + +/** + * Checks if the CCP database has zero instances. If empty, discovers + * the parent instance and auto-registers it. + * Called 5s after server startup via setTimeout. + */ +export async function autoDiscoverOnStartup(): Promise { + const count = await prisma.instance.count(); + if (count > 0) { + logger.debug('[discovery] Instances already exist, skipping auto-discovery'); + return; + } + + logger.info('[discovery] No instances found — running auto-discovery...'); + + const result = await discoverInstances(); + if (result.instances.length === 0) { + logger.info('[discovery] No CML instances discovered on this host'); + return; + } + + // Auto-register parent instance first, then any others + const sorted = [...result.instances].sort((a, b) => { + if (a.isParentInstance && !b.isParentInstance) return -1; + if (!a.isParentInstance && b.isParentInstance) return 1; + return 0; + }); + + // Get or create a system user ID for audit logging + const systemUser = await prisma.ccpUser.findFirst({ + where: { role: 'SUPER_ADMIN' }, + select: { id: true }, + }); + const userId = systemUser?.id || 'system'; + + let imported = 0; + for (const inst of sorted) { + if (inst.isAlreadyRegistered) continue; + try { + await registerInstance( + { + name: inst.name, + slug: inst.slug, + domain: inst.domain, + basePath: inst.basePath, + composeProject: inst.composeProject, + portConfig: inst.portConfig, + adminEmail: inst.adminEmail, + enableMedia: inst.enableMedia, + enableChat: inst.enableChat, + enableGancio: inst.enableGancio, + enableListmonk: inst.enableListmonk, + enableMonitoring: inst.enableMonitoring, + enableDevTools: inst.enableDevTools, + enablePayments: inst.enablePayments, + }, + userId, + 'auto-discovery' + ); + imported++; + logger.info(`[discovery] Auto-registered instance: ${inst.name} (${inst.domain})`); + } catch (err) { + logger.warn(`[discovery] Failed to auto-register ${inst.name}: ${(err as Error).message}`); + } + } + + logger.info(`[discovery] Auto-discovery complete: ${imported}/${sorted.filter((s) => !s.isAlreadyRegistered).length} instances registered`); +} diff --git a/changemaker-control-panel/api/src/services/docker.service.ts b/changemaker-control-panel/api/src/services/docker.service.ts new file mode 100644 index 00000000..2c6162d3 --- /dev/null +++ b/changemaker-control-panel/api/src/services/docker.service.ts @@ -0,0 +1,351 @@ +import { exec as execCb } from 'child_process'; +import { promisify } from 'util'; +import http from 'http'; +import { logger } from '../utils/logger'; + +const exec = promisify(execCb); + +const EXEC_TIMEOUT = 120_000; // 2 minutes +const DOCKER_SOCKET = '/var/run/docker.sock'; + +/** Validate a service/project name to prevent shell injection. */ +function validateName(name: string, label: string): string { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { + throw new Error(`Invalid ${label}: ${name}`); + } + return name; +} + +/** Validate a Docker duration format (e.g., "1h", "30m", "24h"). */ +function validateDuration(value: string): string { + if (!/^\d+[smhd]$/.test(value)) { + throw new Error(`Invalid duration: ${value}`); + } + return value; +} + +/** Validate a tail count (positive integer, capped). */ +function validateTail(value: number): number { + const n = Math.max(1, Math.min(value, 5000)); + return Math.floor(n); +} + +/** Parsed container status from `docker compose ps --format json` */ +export interface ContainerInfo { + name: string; + service: string; + status: string; + state: string; + health: string; + ports: string; + createdAt: string; + exitCode: number; +} + +/** + * Execute a shell command with timeout and proper error handling. + */ +async function execCmd( + command: string, + cwd: string, + timeoutMs = EXEC_TIMEOUT +): Promise<{ stdout: string; stderr: string }> { + logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`); + try { + const result = await exec(command, { + cwd, + timeout: timeoutMs, + maxBuffer: 10 * 1024 * 1024, // 10MB + env: { ...process.env, COMPOSE_ANSI: 'never' }, + }); + return result; + } catch (err: unknown) { + const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean }; + if (error.killed) { + throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`); + } + // Include stderr in the error for debugging + const msg = error.stderr || error.message || 'Unknown exec error'; + throw new Error(`Command failed: ${command}\n${msg}`); + } +} + +/** + * Build the base compose command with project name. + */ +function composeCmd(project: string): string { + return `docker compose -p ${validateName(project, 'project')}`; +} + +// ─── Docker Compose CLI Operations ─────────────────────────────────── + +export async function composeUp( + projectDir: string, + project: string, + services?: string[] +): Promise { + const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : ''; + const orphanFlag = services?.length ? '' : ' --remove-orphans'; + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} up -d${orphanFlag}${svc}`, + projectDir + ); + return stdout || stderr; +} + +export async function composeDown( + projectDir: string, + project: string, + removeVolumes = false +): Promise { + const flags = removeVolumes ? ' -v' : ''; + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} down${flags}`, + projectDir + ); + return stdout || stderr; +} + +export async function composeStop( + projectDir: string, + project: string +): Promise { + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} stop`, + projectDir + ); + return stdout || stderr; +} + +export async function composeRestart( + projectDir: string, + project: string, + service?: string +): Promise { + const svc = service ? ` ${validateName(service, 'service')}` : ''; + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} restart${svc}`, + projectDir + ); + return stdout || stderr; +} + +export async function composePull( + projectDir: string, + project: string +): Promise { + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} pull`, + projectDir, + 300_000 // 5 min for pulls + ); + return stdout || stderr; +} + +export async function composeBuild( + projectDir: string, + project: string +): Promise { + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} build`, + projectDir, + 600_000 // 10 min for builds + ); + return stdout || stderr; +} + +/** + * List containers with status. Returns parsed container info. + */ +export async function composePs( + projectDir: string, + project: string +): Promise { + const { stdout } = await execCmd( + `${composeCmd(project)} ps --format json`, + projectDir + ); + + if (!stdout.trim()) return []; + + // docker compose ps --format json outputs one JSON object per line + const containers: ContainerInfo[] = []; + for (const line of stdout.trim().split('\n')) { + if (!line.trim()) continue; + try { + const raw = JSON.parse(line); + containers.push({ + name: raw.Name || raw.name || '', + service: raw.Service || raw.service || '', + status: raw.Status || raw.status || '', + state: raw.State || raw.state || '', + health: raw.Health || raw.health || '', + ports: raw.Ports || raw.ports || '', + createdAt: raw.CreatedAt || raw.created_at || '', + exitCode: raw.ExitCode ?? raw.exit_code ?? 0, + }); + } catch { + logger.warn(`[docker] Failed to parse container line: ${line}`); + } + } + return containers; +} + +/** + * Get logs from a specific service. + */ +export async function composeLogs( + projectDir: string, + project: string, + service?: string, + tail = 200, + since?: string +): Promise { + const parts = [composeCmd(project), 'logs', '--no-color']; + if (tail > 0) parts.push(`--tail=${validateTail(tail)}`); + if (since) parts.push(`--since=${validateDuration(since)}`); + if (service) parts.push(validateName(service, 'service')); + + const { stdout, stderr } = await execCmd(parts.join(' '), projectDir); + return stdout || stderr; +} + +/** + * Execute a command inside a running service container. + * Optionally pass environment variables via -e flags. + */ +export async function composeExec( + projectDir: string, + project: string, + service: string, + command: string, + timeoutMs = EXEC_TIMEOUT, + envVars?: Record +): Promise { + const envFlags = envVars + ? Object.entries(envVars).map(([k, v]) => `-e ${k}=${v}`).join(' ') + ' ' + : ''; + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`, + projectDir, + timeoutMs + ); + return stdout || stderr; +} + +/** + * Run a one-off command in a service container (docker compose run). + * Uses --entrypoint "" to skip the service's entrypoint script. + * Useful for running setup commands (prisma db push, seed) without the entrypoint. + */ +export async function composeRun( + projectDir: string, + project: string, + service: string, + command: string, + timeoutMs = EXEC_TIMEOUT +): Promise { + const { stdout, stderr } = await execCmd( + `${composeCmd(project)} run --rm --no-deps -T --entrypoint "" ${validateName(service, 'service')} ${command}`, + projectDir, + timeoutMs + ); + return stdout || stderr; +} + +// ─── Docker Socket API ─────────────────────────────────────────────── + +/** + * Make a request to the Docker Engine API via Unix socket. + */ +function dockerSocketRequest(path: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: DOCKER_SOCKET, + path, + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`Docker API returned ${res.statusCode}: ${data}`)); + } else { + resolve(data); + } + }); + } + ); + req.on('error', reject); + req.setTimeout(10_000, () => { + req.destroy(); + reject(new Error('Docker socket request timed out')); + }); + req.end(); + }); +} + +/** + * Get status of a specific container by name via Docker socket API. + */ +export async function getContainerStatus( + containerName: string +): Promise<{ state: string; health: string; running: boolean } | null> { + try { + const data = await dockerSocketRequest( + `/containers/${encodeURIComponent(containerName)}/json` + ); + const info = JSON.parse(data); + return { + state: info.State?.Status || 'unknown', + health: info.State?.Health?.Status || 'none', + running: info.State?.Running === true, + }; + } catch { + return null; + } +} + +/** + * Poll until a container reaches healthy state or timeout expires. + */ +export async function waitForHealthy( + containerName: string, + timeoutMs = 60_000, + pollIntervalMs = 2_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = await getContainerStatus(containerName); + if (status?.health === 'healthy') return true; + if (status?.state === 'exited' || status?.state === 'dead') { + throw new Error(`Container ${containerName} exited unexpectedly`); + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`); +} + +/** + * Wait for an HTTP endpoint to respond with 200. + */ +export async function waitForHttp( + url: string, + timeoutMs = 120_000, + pollIntervalMs = 3_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (response.ok) return true; + } catch { + // Expected while service is starting + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + throw new Error(`HTTP endpoint ${url} did not respond within ${timeoutMs}ms`); +} diff --git a/changemaker-control-panel/api/src/services/health.service.ts b/changemaker-control-panel/api/src/services/health.service.ts new file mode 100644 index 00000000..27995e43 --- /dev/null +++ b/changemaker-control-panel/api/src/services/health.service.ts @@ -0,0 +1,191 @@ +import { InstanceStatus, HealthStatus } from '@prisma/client'; +import { prisma } from '../lib/prisma'; +import * as docker from './docker.service'; +import { logger } from '../utils/logger'; +import type { ContainerInfo } from './docker.service'; + +/** + * Determine overall health status from container list. + */ +function determineHealth(containers: ContainerInfo[]): { + status: HealthStatus; + serviceStatus: Record; + totalServices: number; + healthyServices: number; +} { + if (containers.length === 0) { + return { status: HealthStatus.UNKNOWN, serviceStatus: {}, totalServices: 0, healthyServices: 0 }; + } + + const serviceStatus: Record = {}; + let healthyCount = 0; + let runningCount = 0; + + for (const c of containers) { + serviceStatus[c.service || c.name] = { + state: c.state, + health: c.health, + }; + + const isRunning = c.state === 'running'; + if (isRunning) runningCount++; + + // A service is "healthy" if it's running AND either has no health check or passes it + const isHealthy = isRunning && (c.health === '' || c.health === 'healthy'); + if (isHealthy) healthyCount++; + } + + const total = containers.length; + let status: HealthStatus; + + if (healthyCount === total) { + status = HealthStatus.HEALTHY; + } else if (runningCount === 0) { + status = HealthStatus.UNHEALTHY; + } else if (healthyCount >= total / 2) { + status = HealthStatus.DEGRADED; + } else { + status = HealthStatus.UNHEALTHY; + } + + return { status, serviceStatus, totalServices: total, healthyServices: healthyCount }; +} + +/** + * Check the health of a single instance. Returns the created HealthCheck record. + */ +export async function checkInstanceHealth(instanceId: string) { + const instance = await prisma.instance.findUnique({ where: { id: instanceId } }); + if (!instance) { + throw new Error(`Instance ${instanceId} not found`); + } + + if (instance.status !== InstanceStatus.RUNNING) { + throw new Error(`Instance ${instance.slug} is not running (status: ${instance.status})`); + } + + const startTime = Date.now(); + let containers: ContainerInfo[]; + + try { + containers = await docker.composePs(instance.basePath, instance.composeProject); + } catch (err) { + // If compose ps fails, record UNKNOWN status + const healthCheck = await prisma.healthCheck.create({ + data: { + instanceId, + status: HealthStatus.UNKNOWN, + serviceStatus: {}, + totalServices: 0, + healthyServices: 0, + responseTimeMs: Date.now() - startTime, + }, + }); + + await prisma.instance.update({ + where: { id: instanceId }, + data: { lastHealthCheck: new Date() }, + }); + + logger.warn(`[health] ${instance.slug}: compose ps failed: ${(err as Error).message}`); + return healthCheck; + } + + const responseTimeMs = Date.now() - startTime; + const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers); + + const healthCheck = await prisma.healthCheck.create({ + data: { + instanceId, + status, + serviceStatus, + totalServices, + healthyServices, + responseTimeMs, + }, + }); + + await prisma.instance.update({ + where: { id: instanceId }, + data: { lastHealthCheck: new Date() }, + }); + + return healthCheck; +} + +/** + * Check all running instances sequentially. + */ +export async function checkAllInstances(): Promise { + const instances = await prisma.instance.findMany({ + where: { status: InstanceStatus.RUNNING }, + select: { id: true, slug: true }, + }); + + if (instances.length === 0) { + logger.debug('[health] No running instances to check'); + return; + } + + let healthy = 0; + let degraded = 0; + let unhealthy = 0; + + for (const inst of instances) { + try { + const check = await checkInstanceHealth(inst.id); + if (check.status === HealthStatus.HEALTHY) healthy++; + else if (check.status === HealthStatus.DEGRADED) degraded++; + else unhealthy++; + } catch (err) { + logger.warn(`[health] Failed to check ${inst.slug}: ${(err as Error).message}`); + unhealthy++; + } + } + + logger.info( + `[health] Checked ${instances.length} instances: ${healthy} healthy, ${degraded} degraded, ${unhealthy} unhealthy` + ); +} + +/** + * Get paginated health history for an instance. + */ +export async function getHealthHistory(instanceId: string, page = 1, limit = 20) { + const [data, total] = await Promise.all([ + prisma.healthCheck.findMany({ + where: { instanceId }, + orderBy: { checkedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.healthCheck.count({ where: { instanceId } }), + ]); + + return { data, total, page, limit }; +} + +/** + * Start the periodic health check scheduler. + */ +export function startHealthScheduler(intervalMs: number): NodeJS.Timeout | null { + if (intervalMs <= 0) { + logger.info('[health] Automated health checks disabled (interval=0)'); + return null; + } + + logger.info(`[health] Starting health scheduler (interval: ${intervalMs}ms)`); + + // Run initial check after a short delay (let services start) + setTimeout(() => { + checkAllInstances().catch((err) => + logger.error(`[health] Initial check failed: ${(err as Error).message}`) + ); + }, 10_000); + + return setInterval(() => { + checkAllInstances().catch((err) => + logger.error(`[health] Scheduled check failed: ${(err as Error).message}`) + ); + }, intervalMs); +} diff --git a/changemaker-control-panel/api/src/services/port-allocator.ts b/changemaker-control-panel/api/src/services/port-allocator.ts new file mode 100644 index 00000000..b6252433 --- /dev/null +++ b/changemaker-control-panel/api/src/services/port-allocator.ts @@ -0,0 +1,94 @@ +import { prisma } from '../lib/prisma'; +import { env } from '../config/env'; +import { AppError } from '../middleware/error-handler'; + +interface PortRangeConfig { + service: string; + start: number; + end: number; +} + +function getPortRanges(): PortRangeConfig[] { + return [ + { service: 'api', start: env.PORT_RANGE_API_START, end: env.PORT_RANGE_API_END }, + { service: 'admin', start: env.PORT_RANGE_ADMIN_START, end: env.PORT_RANGE_ADMIN_END }, + { service: 'postgres', start: env.PORT_RANGE_POSTGRES_START, end: env.PORT_RANGE_POSTGRES_END }, + { service: 'nginx', start: env.PORT_RANGE_NGINX_START, end: env.PORT_RANGE_NGINX_END }, + { service: 'embed', start: env.PORT_RANGE_EMBED_START, end: env.PORT_RANGE_EMBED_END }, + ]; +} + +async function findNextAvailablePort( + start: number, + end: number, + tx?: Parameters[0]>[0] +): Promise { + const client = tx || prisma; + const allocated = await client.portAllocation.findMany({ + where: { port: { gte: start, lte: end } }, + select: { port: true }, + orderBy: { port: 'asc' }, + }); + + const usedPorts = new Set(allocated.map((a) => a.port)); + + for (let port = start; port <= end; port++) { + if (!usedPorts.has(port)) { + return port; + } + } + + throw new AppError(503, `No available ports in range ${start}-${end}`, 'PORT_EXHAUSTED'); +} + +export interface AllocatedPorts { + config: Record; + allocations: Array<{ port: number; service: string }>; +} + +/** + * Allocate ports using a serializable transaction to prevent race conditions. + * Port allocation records are created immediately to act as a DB-level lock. + * They are linked to the instance later via instances.service.ts. + */ +export async function allocatePorts(): Promise { + return prisma.$transaction(async (tx) => { + const ranges = getPortRanges(); + const config: Record = {}; + const allocations: Array<{ port: number; service: string }> = []; + + for (const range of ranges) { + const port = await findNextAvailablePort(range.start, range.end, tx); + config[range.service] = port; + allocations.push({ port, service: range.service }); + } + + return { config, allocations }; + }); +} + +export async function releasePorts(instanceId: string): Promise { + await prisma.portAllocation.deleteMany({ where: { instanceId } }); +} + +export async function getPortUsage(): Promise<{ + ranges: Array<{ service: string; start: number; end: number; used: number; total: number }>; +}> { + const ranges = getPortRanges(); + const result = []; + + for (const range of ranges) { + const used = await prisma.portAllocation.count({ + where: { port: { gte: range.start, lte: range.end } }, + }); + result.push({ + service: range.service, + start: range.start, + end: range.end, + used, + total: range.end - range.start + 1, + }); + } + + return { ranges: result }; +} diff --git a/changemaker-control-panel/api/src/services/secret-generator.ts b/changemaker-control-panel/api/src/services/secret-generator.ts new file mode 100644 index 00000000..f464511b --- /dev/null +++ b/changemaker-control-panel/api/src/services/secret-generator.ts @@ -0,0 +1,73 @@ +import crypto from 'crypto'; + +function randomHex(bytes = 32): string { + return crypto.randomBytes(bytes).toString('hex'); +} + +function randomPassword(length = 16): string { + // Generate password meeting CML policy: 12+ chars, uppercase, lowercase, digit + const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lower = 'abcdefghijklmnopqrstuvwxyz'; + const digits = '0123456789'; + // Avoid $, #, !, % — they break Docker Compose .env files + // ($=var expansion, #=comment, !=bash history, %=printf) + const special = '@&*_-+='; + const all = upper + lower + digits + special; + + // Ensure at least one of each required class + const required = [ + upper[crypto.randomInt(upper.length)], + lower[crypto.randomInt(lower.length)], + digits[crypto.randomInt(digits.length)], + special[crypto.randomInt(special.length)], + ]; + + // Fill remaining with random chars + const remaining = Array.from({ length: length - required.length }, () => + all[crypto.randomInt(all.length)] + ); + + // Shuffle + const chars = [...required, ...remaining]; + for (let i = chars.length - 1; i > 0; i--) { + const j = crypto.randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + + return chars.join(''); +} + +export interface InstanceSecrets { + postgresPassword: string; + redisPassword: string; + jwtAccessSecret: string; + jwtRefreshSecret: string; + encryptionKey: string; + initialAdminPassword: string; + nocodbAdminPassword: string; + grafanaAdminPassword: string; + listmonkAdminPassword: string; + listmonkApiToken: string; + giteaAdminPassword: string; + n8nEncryptionKey: string; + gancioAdminPassword: string; +} + +export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEmail: string } { + return { + adminEmail, + postgresPassword: randomHex(16), + redisPassword: randomHex(16), + jwtAccessSecret: randomHex(32), + jwtRefreshSecret: randomHex(32), + encryptionKey: randomHex(32), + initialAdminPassword: randomPassword(16), + nocodbAdminPassword: randomPassword(16), + grafanaAdminPassword: randomPassword(16), + listmonkAdminPassword: randomPassword(16), + listmonkApiToken: randomHex(16), + giteaAdminPassword: randomPassword(16), + n8nEncryptionKey: randomHex(32), + gancioAdminPassword: randomPassword(16), + }; +} diff --git a/changemaker-control-panel/api/src/services/template-engine.ts b/changemaker-control-panel/api/src/services/template-engine.ts new file mode 100644 index 00000000..f1a463aa --- /dev/null +++ b/changemaker-control-panel/api/src/services/template-engine.ts @@ -0,0 +1,227 @@ +import Handlebars from 'handlebars'; +import fs from 'fs/promises'; +import path from 'path'; +import { logger } from '../utils/logger'; + +// Register helpers +Handlebars.registerHelper('ifEq', function (this: unknown, a: unknown, b: unknown, options: Handlebars.HelperOptions) { + return a === b ? options.fn(this) : options.inverse(this); +}); + +Handlebars.registerHelper('unless', function (this: unknown, condition: unknown, options: Handlebars.HelperOptions) { + return !condition ? options.fn(this) : options.inverse(this); +}); + +Handlebars.registerHelper('now', function () { + return new Date().toISOString(); +}); + +Handlebars.registerHelper('math', function (a: number, op: string, b: number) { + switch (op) { + case '+': return a + b; + case '-': return a - b; + default: return a; + } +}); + +export interface TemplateContext { + // Instance identity + slug: string; + name: string; + domain: string; + containerPrefix: string; // e.g., "cml-better-edmonton" + networkName: string; // e.g., "cml-better-edmonton" + composeProject: string; // e.g., "cml-better-edmonton" + + // Ports + ports: { + api: number; + admin: number; + postgres: number; + nginx: number; + embed: number; + }; + + // Secrets (decrypted) + secrets: { + postgresPassword: string; + redisPassword: string; + jwtAccessSecret: string; + jwtRefreshSecret: string; + encryptionKey: string; + initialAdminPassword: string; + adminEmail: string; + nocodbAdminPassword: string; + grafanaAdminPassword: string; + listmonkAdminPassword: string; + listmonkApiToken: string; + giteaAdminPassword: string; + n8nEncryptionKey: string; + gancioAdminPassword: string; + }; + + // Feature flags + enableMedia: boolean; + enableChat: boolean; + enableGancio: boolean; + enableListmonk: boolean; + enableMonitoring: boolean; + enableDevTools: boolean; + enablePayments: boolean; + + // SMTP + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpFrom: string; + emailTestMode: boolean; + + // Git + gitBranch: string; +} + +/** Subset of Instance fields needed to build a TemplateContext. */ +export interface InstanceForTemplate { + slug: string; + name: string; + domain: string; + composeProject: string; + portConfig: unknown; + enableMedia: boolean; + enableChat: boolean; + enableGancio: boolean; + enableListmonk: boolean; + enableMonitoring: boolean; + enableDevTools: boolean; + enablePayments: boolean; + smtpHost: string | null; + smtpPort: number | null; + smtpUser: string | null; + smtpFrom: string | null; + emailTestMode: boolean; + gitBranch: string; +} + +/** + * Build the TemplateContext from an instance record and its decrypted secrets. + */ +export function buildTemplateContext( + instance: InstanceForTemplate, + secrets: Record +): TemplateContext { + const ports = instance.portConfig as Record; + return { + slug: instance.slug, + name: instance.name, + domain: instance.domain, + containerPrefix: instance.composeProject, + networkName: instance.composeProject, + composeProject: instance.composeProject, + ports: { + api: ports.api, + admin: ports.admin, + postgres: ports.postgres, + nginx: ports.nginx, + embed: ports.embed, + }, + secrets: { + postgresPassword: secrets.postgresPassword, + redisPassword: secrets.redisPassword, + jwtAccessSecret: secrets.jwtAccessSecret, + jwtRefreshSecret: secrets.jwtRefreshSecret, + encryptionKey: secrets.encryptionKey, + initialAdminPassword: secrets.initialAdminPassword, + adminEmail: secrets.adminEmail, + nocodbAdminPassword: secrets.nocodbAdminPassword, + grafanaAdminPassword: secrets.grafanaAdminPassword, + listmonkAdminPassword: secrets.listmonkAdminPassword, + listmonkApiToken: secrets.listmonkApiToken, + giteaAdminPassword: secrets.giteaAdminPassword, + n8nEncryptionKey: secrets.n8nEncryptionKey, + gancioAdminPassword: secrets.gancioAdminPassword, + }, + enableMedia: instance.enableMedia, + enableChat: instance.enableChat, + enableGancio: instance.enableGancio, + enableListmonk: instance.enableListmonk, + enableMonitoring: instance.enableMonitoring, + enableDevTools: instance.enableDevTools, + enablePayments: instance.enablePayments, + smtpHost: instance.smtpHost || '', + smtpPort: instance.smtpPort || 587, + smtpUser: instance.smtpUser || '', + smtpFrom: instance.smtpFrom || '', + emailTestMode: instance.emailTestMode, + gitBranch: instance.gitBranch, + }; +} + +const templateCache = new Map(); + +async function loadTemplate(templatePath: string): Promise { + if (templateCache.has(templatePath)) { + return templateCache.get(templatePath)!; + } + + const source = await fs.readFile(templatePath, 'utf-8'); + const compiled = Handlebars.compile(source, { noEscape: true }); + templateCache.set(templatePath, compiled); + return compiled; +} + +export async function renderTemplate(templateName: string, context: TemplateContext): Promise { + const templatesDir = path.resolve(__dirname, '../..', 'templates'); + const templatePath = path.join(templatesDir, templateName); + const template = await loadTemplate(templatePath); + return template(context); +} + +export async function renderAllTemplates(context: TemplateContext, outputDir: string): Promise { + const templatesDir = path.resolve(__dirname, '../..', 'templates'); + + const templateFiles = [ + { template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' }, + { template: 'env.hbs', output: '.env' }, + { template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' }, + { template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' }, + { template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' }, + { template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' }, + { template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' }, + ]; + + for (const { template, output } of templateFiles) { + const templatePath = path.join(templatesDir, template); + try { + await fs.access(templatePath); + } catch { + logger.warn(`Template not found: ${template}, skipping`); + continue; + } + + const rendered = await renderTemplate(template, context); + const outputPath = path.join(outputDir, output); + + // Ensure output directory exists + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, rendered, 'utf-8'); + logger.debug(`Rendered ${template} → ${outputPath}`); + } + + // Copy static files (nginx.conf doesn't need templating) + const staticFiles = ['nginx/nginx.conf']; + for (const file of staticFiles) { + const srcPath = path.join(templatesDir, file); + try { + await fs.access(srcPath); + const destPath = path.join(outputDir, file); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + } catch { + logger.warn(`Static file not found: ${file}, skipping`); + } + } +} + +export function clearTemplateCache(): void { + templateCache.clear(); +} diff --git a/changemaker-control-panel/api/src/utils/encryption.ts b/changemaker-control-panel/api/src/utils/encryption.ts new file mode 100644 index 00000000..937c284c --- /dev/null +++ b/changemaker-control-panel/api/src/utils/encryption.ts @@ -0,0 +1,37 @@ +import crypto from 'crypto'; +import { env } from '../config/env'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + return Buffer.from(env.ENCRYPTION_KEY, 'hex').subarray(0, 32); +} + +export function encrypt(plaintext: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // Format: iv:tag:ciphertext (all base64) + return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':'); +} + +export function decrypt(encryptedText: string): string { + const [ivB64, tagB64, ciphertextB64] = encryptedText.split(':'); + const iv = Buffer.from(ivB64, 'base64'); + const tag = Buffer.from(tagB64, 'base64'); + const ciphertext = Buffer.from(ciphertextB64, 'base64'); + const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +export function encryptJson(data: Record): string { + return encrypt(JSON.stringify(data)); +} + +export function decryptJson>(encryptedText: string): T { + return JSON.parse(decrypt(encryptedText)) as T; +} diff --git a/changemaker-control-panel/api/src/utils/logger.ts b/changemaker-control-panel/api/src/utils/logger.ts new file mode 100644 index 00000000..0342bfd7 --- /dev/null +++ b/changemaker-control-panel/api/src/utils/logger.ts @@ -0,0 +1,13 @@ +import winston from 'winston'; + +export const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + process.env.NODE_ENV === 'production' + ? winston.format.json() + : winston.format.combine(winston.format.colorize(), winston.format.simple()) + ), + transports: [new winston.transports.Console()], +}); diff --git a/changemaker-control-panel/api/tsconfig.json b/changemaker-control-panel/api/tsconfig.json new file mode 100644 index 00000000..ba2e59df --- /dev/null +++ b/changemaker-control-panel/api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@prisma/*": ["prisma/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/changemaker-control-panel/docker-compose.yml b/changemaker-control-panel/docker-compose.yml new file mode 100644 index 00000000..95223a60 --- /dev/null +++ b/changemaker-control-panel/docker-compose.yml @@ -0,0 +1,91 @@ +# Changemaker Control Panel — Docker Compose +# Start: docker compose up -d + +services: + ccp-postgres: + image: postgres:16-alpine + container_name: ccp-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ccp + POSTGRES_PASSWORD: ${CCP_POSTGRES_PASSWORD:-ccp_secret} + POSTGRES_DB: ccp + volumes: + - ccp-postgres-data:/var/lib/postgresql/data + ports: + - "127.0.0.1:${CCP_POSTGRES_PORT:-5480}:5432" + networks: + - ccp-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ccp -d ccp"] + interval: 10s + timeout: 5s + retries: 5 + + ccp-redis: + image: redis:7-alpine + container_name: ccp-redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD:-ccp_redis_secret} + volumes: + - ccp-redis-data:/data + ports: + - "127.0.0.1:${CCP_REDIS_PORT:-6399}:6379" + networks: + - ccp-network + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-ccp_redis_secret}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + ccp-api: + build: + context: . + dockerfile: Dockerfile.api + container_name: ccp-api + restart: unless-stopped + depends_on: + ccp-postgres: + condition: service_healthy + ccp-redis: + condition: service_healthy + env_file: .env + environment: + DATABASE_URL: postgresql://ccp:${CCP_POSTGRES_PASSWORD:-ccp_secret}@ccp-postgres:5432/ccp + REDIS_URL: redis://:${REDIS_PASSWORD:-ccp_redis_secret}@ccp-redis:6379 + ports: + - "${CCP_API_PORT:-5000}:5000" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./api:/app + - /app/node_modules + - ./templates:/app/templates:ro + - /var/run/docker.sock:/var/run/docker.sock + - ${INSTANCES_BASE_PATH}:${INSTANCES_BASE_PATH} + - ${CML_SOURCE_PATH}:${CML_SOURCE_PATH}:ro + - ${BACKUP_STORAGE_PATH}:${BACKUP_STORAGE_PATH} + networks: + - ccp-network + + ccp-admin: + build: + context: . + dockerfile: Dockerfile.admin + container_name: ccp-admin + restart: unless-stopped + depends_on: + - ccp-api + ports: + - "${CCP_ADMIN_PORT:-5100}:5100" + networks: + - ccp-network + +volumes: + ccp-postgres-data: + ccp-redis-data: + +networks: + ccp-network: + driver: bridge diff --git a/changemaker-control-panel/setup.sh b/changemaker-control-panel/setup.sh new file mode 100755 index 00000000..40982285 --- /dev/null +++ b/changemaker-control-panel/setup.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# ============================================================ +# Changemaker Control Panel — Setup Script +# ============================================================ +# Detects the installation directory and configures .env with +# resolved absolute paths. Run once after cloning, or re-run +# any time you move the CCP directory. +# +# Usage: +# cd changemaker-control-panel +# chmod +x setup.sh +# ./setup.sh +# ============================================================ + +set -euo pipefail + +# ── Resolve absolute CCP directory ────────────────────────── +CCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Derive paths ──────────────────────────────────────────── +INSTANCES_PATH="${CCP_DIR}/instances" +BACKUP_PATH="${CCP_DIR}/backups" + +# Auto-detect CML source path (CCP is expected inside the CML repo) +CML_SOURCE="${CCP_DIR%/changemaker-control-panel}" +if [[ ! -f "${CML_SOURCE}/docker-compose.yml" ]]; then + CML_SOURCE="" +fi + +echo "" +echo "Changemaker Control Panel — Setup" +echo "==================================" +echo " CCP directory: ${CCP_DIR}" +echo " Instances path: ${INSTANCES_PATH}" +echo " Backup path: ${BACKUP_PATH}" +[[ -n "${CML_SOURCE}" ]] && echo " CML source: ${CML_SOURCE}" +echo "" + +# ── Create directories ────────────────────────────────────── +mkdir -p "${INSTANCES_PATH}" +mkdir -p "${BACKUP_PATH}" +echo "✓ Created instance and backup directories" + +# ── Create .env from .env.example if it doesn't exist ─────── +if [[ ! -f "${CCP_DIR}/.env" ]]; then + if [[ -f "${CCP_DIR}/.env.example" ]]; then + cp "${CCP_DIR}/.env.example" "${CCP_DIR}/.env" + echo "✓ Created .env from .env.example" + else + echo "⚠ No .env.example found — creating minimal .env" + touch "${CCP_DIR}/.env" + fi +fi + +# ── Helper: set or update a key in .env ───────────────────── +update_env() { + local key="$1" value="$2" file="${CCP_DIR}/.env" + if grep -q "^${key}=" "$file" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${value}|" "$file" + else + echo "${key}=${value}" >> "$file" + fi +} + +# ── Set resolved paths ────────────────────────────────────── +update_env "INSTANCES_BASE_PATH" "${INSTANCES_PATH}" +update_env "BACKUP_STORAGE_PATH" "${BACKUP_PATH}" +[[ -n "${CML_SOURCE}" ]] && update_env "CML_SOURCE_PATH" "${CML_SOURCE}" +echo "✓ Updated .env with resolved absolute paths" + +# ── Generate random secrets if still using placeholders ───── +generate_secret() { + openssl rand -hex 32 +} + +for key in JWT_ACCESS_SECRET JWT_REFRESH_SECRET ENCRYPTION_KEY; do + current=$(grep "^${key}=" "${CCP_DIR}/.env" 2>/dev/null | cut -d= -f2- || true) + if [[ "$current" == *"change-me"* ]] || \ + [[ "$current" =~ ^[a]{32,}$ ]] || \ + [[ "$current" =~ ^[b]{32,}$ ]] || \ + [[ "$current" =~ ^[c]{32,}$ ]]; then + update_env "$key" "$(generate_secret)" + echo "✓ Generated random ${key}" + fi +done + +echo "" +echo "Setup complete! Next steps:" +echo " 1. Review ${CCP_DIR}/.env and adjust settings as needed" +echo " 2. Run: docker compose up -d" +echo "" diff --git a/changemaker-control-panel/templates/configs/pangolin/resources.yml.hbs b/changemaker-control-panel/templates/configs/pangolin/resources.yml.hbs new file mode 100644 index 00000000..04831a29 --- /dev/null +++ b/changemaker-control-panel/templates/configs/pangolin/resources.yml.hbs @@ -0,0 +1,56 @@ +# Pangolin Resources — Instance: {{name}} +# Maps subdomains to internal services via Newt tunnel + +resources: + - name: app + subdomain: app + target: http://{{containerPrefix}}-nginx:80 + isBaseDomain: false + + - name: api + subdomain: api + target: http://{{containerPrefix}}-nginx:80 + isBaseDomain: false + +{{#if enableMedia}} + - name: media + subdomain: media + target: http://{{containerPrefix}}-nginx:80 + isBaseDomain: false +{{/if}} + + - name: docs + subdomain: docs + target: http://{{containerPrefix}}-mkdocs:8000 + isBaseDomain: false + + - name: db + subdomain: db + target: http://{{containerPrefix}}-nocodb:8080 + isBaseDomain: false + +{{#if enableListmonk}} + - name: listmonk + subdomain: listmonk + target: http://{{containerPrefix}}-listmonk:9000 + isBaseDomain: false +{{/if}} + +{{#if enableGancio}} + - name: events + subdomain: events + target: http://{{containerPrefix}}-gancio:13120 + isBaseDomain: false +{{/if}} + +{{#if enableMonitoring}} + - name: grafana + subdomain: grafana + target: http://{{containerPrefix}}-grafana:3000 + isBaseDomain: false +{{/if}} + + - name: root + subdomain: "" + target: http://{{containerPrefix}}-mkdocs:8000 + isBaseDomain: true diff --git a/changemaker-control-panel/templates/configs/prometheus/prometheus.yml.hbs b/changemaker-control-panel/templates/configs/prometheus/prometheus.yml.hbs new file mode 100644 index 00000000..eba5098e --- /dev/null +++ b/changemaker-control-panel/templates/configs/prometheus/prometheus.yml.hbs @@ -0,0 +1,22 @@ +# Prometheus — Instance: {{name}} + +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: '{{composeProject}}-api' + static_configs: + - targets: ['{{containerPrefix}}-api:4000'] + metrics_path: /api/metrics + +{{#if enableMedia}} + - job_name: '{{composeProject}}-media-api' + static_configs: + - targets: ['{{containerPrefix}}-media-api:4100'] + metrics_path: /api/metrics +{{/if}} + + - job_name: '{{composeProject}}-redis' + static_configs: + - targets: ['{{containerPrefix}}-redis-exporter:9121'] diff --git a/changemaker-control-panel/templates/docker-compose.yml.hbs b/changemaker-control-panel/templates/docker-compose.yml.hbs new file mode 100644 index 00000000..f6494b02 --- /dev/null +++ b/changemaker-control-panel/templates/docker-compose.yml.hbs @@ -0,0 +1,658 @@ +# Changemaker Lite — Instance: {{name}} +# Compose project: {{composeProject}} +# Generated by CCP + +services: + # ─── Core Infrastructure ─────────────────────────────────── + + v2-postgres: + image: postgres:16-alpine + container_name: {{containerPrefix}}-postgres + restart: unless-stopped + environment: + POSTGRES_USER: changemaker + POSTGRES_PASSWORD: {{secrets.postgresPassword}} + POSTGRES_DB: changemaker_v2 + volumes: + - {{containerPrefix}}-postgres-data:/var/lib/postgresql/data + - ./api/prisma/init-nocodb-db.sh:/docker-entrypoint-initdb.d/10-init-nocodb.sh:ro + - ./api/prisma/init-gancio-db.sh:/docker-entrypoint-initdb.d/20-init-gancio.sh:ro + ports: + - "127.0.0.1:{{ports.postgres}}:5432" + networks: + - {{networkName}} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U changemaker -d changemaker_v2"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: {{containerPrefix}}-redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass {{secrets.redisPassword}} + volumes: + - {{containerPrefix}}-redis-data:/data + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "redis-cli", "-a", "{{secrets.redisPassword}}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + cpus: '1' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + logging: + driver: "json-file" + options: + max-size: "5m" + max-file: "2" + + # ─── Application Services ────────────────────────────────── + + api: + build: + context: ./api + dockerfile: Dockerfile + target: development + container_name: {{containerPrefix}}-api + restart: unless-stopped + depends_on: + v2-postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + environment: + DATABASE_URL: postgresql://changemaker:{{secrets.postgresPassword}}@{{containerPrefix}}-postgres:5432/changemaker_v2 + REDIS_URL: redis://:{{secrets.redisPassword}}@{{containerPrefix}}-redis:6379 + PORT: "4000" + NAR_DATA_DIR: /data + LISTMONK_URL: http://{{containerPrefix}}-listmonk:9000 + ADMIN_URL: https://app.{{domain}} + API_URL: https://api.{{domain}} +{{#if enableGancio}} + GANCIO_URL: http://{{containerPrefix}}-gancio:13120 +{{/if}} + ports: + - "{{ports.api}}:4000" + volumes: + - ./api:/app + - /app/node_modules + - ./assets/uploads:/app/uploads + - ./mkdocs:/mkdocs:rw + - ./data:/data:ro + - ./configs:/app/configs:ro + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + cpus: '2' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + + admin: + build: + context: ./admin + target: development + container_name: {{containerPrefix}}-admin + restart: unless-stopped + depends_on: + - api + environment: + DOMAIN: {{domain}} + NODE_ENV: production + VITE_API_URL: http://{{containerPrefix}}-api:4000 + VITE_MKDOCS_URL: http://{{containerPrefix}}-mkdocs:8000 +{{#if enableMedia}} + VITE_MEDIA_API_URL: http://{{containerPrefix}}-media-api:4100 +{{/if}} + volumes: + - ./admin:/app + - /app/node_modules + ports: + - "{{ports.admin}}:3000" + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +{{#if enableMedia}} + media-api: + build: + context: ./api + dockerfile: Dockerfile.media + target: development + container_name: {{containerPrefix}}-media-api + restart: unless-stopped + depends_on: + v2-postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + environment: + DATABASE_URL: postgresql://changemaker:{{secrets.postgresPassword}}@{{containerPrefix}}-postgres:5432/changemaker_v2 + REDIS_URL: redis://:{{secrets.redisPassword}}@{{containerPrefix}}-redis:6379 + MEDIA_API_PORT: "4100" + CORS_ORIGINS: https://app.{{domain}},http://localhost:{{ports.admin}} + ENABLE_MEDIA_FEATURES: "true" + MEDIA_ROOT: /media/local + MEDIA_UPLOADS: /media/uploads + volumes: + - ./api:/app + - /app/node_modules + - ./media:/media:ro + - ./media/local/inbox:/media/local/inbox:rw + - ./media/local/thumbnails:/media/local/thumbnails:rw + - ./media/local/photos:/media/local/photos:rw + - ./media/public:/media/public:rw + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4100/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + cpus: '2' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M +{{/if}} + + # ─── Reverse Proxy ───────────────────────────────────────── + + nginx: + image: nginx:alpine + container_name: {{containerPrefix}}-nginx + restart: unless-stopped + depends_on: + - api + - admin + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + ports: + - "{{ports.nginx}}:80" + - "{{math ports.embed "+" 0}}:8881" # NocoDB embed proxy + - "{{math ports.embed "+" 1}}:8882" # n8n embed proxy + - "{{math ports.embed "+" 2}}:8883" # Gitea embed proxy + - "{{math ports.embed "+" 3}}:8884" # MailHog embed proxy + - "{{math ports.embed "+" 4}}:8885" # Mini QR embed proxy + - "{{math ports.embed "+" 5}}:8886" # Excalidraw embed proxy + - "{{math ports.embed "+" 6}}:8887" # Homepage embed proxy + - "{{math ports.embed "+" 7}}:8888" # Code Server embed proxy + - "{{math ports.embed "+" 8}}:8889" # MkDocs embed proxy + - "{{math ports.embed "+" 9}}:8890" # Vaultwarden embed proxy + - "{{math ports.embed "+" 10}}:8891" # Rocket.Chat embed proxy + - "{{math ports.embed "+" 11}}:8892" # Gancio embed proxy + - "{{math ports.embed "+" 12}}:8893" # Grafana embed proxy + - "{{math ports.embed "+" 13}}:8894" # Listmonk embed proxy + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"] + interval: 30s + timeout: 5s + retries: 3 + + # ─── Supporting Services ─────────────────────────────────── + + nocodb-v2: + image: nocodb/nocodb:latest + container_name: {{containerPrefix}}-nocodb + restart: unless-stopped + depends_on: + v2-postgres: + condition: service_healthy + environment: + NC_DB: pg://{{containerPrefix}}-postgres:5432?u=changemaker&p={{secrets.postgresPassword}}&d=nocodb_meta + NC_ADMIN_EMAIL: {{secrets.adminEmail}} + NC_ADMIN_PASSWORD: {{secrets.nocodbAdminPassword}} + volumes: + - {{containerPrefix}}-nocodb-data:/usr/app/data + networks: + - {{networkName}} + + mailhog: + image: mailhog/mailhog:latest + container_name: {{containerPrefix}}-mailhog + restart: unless-stopped + networks: + - {{networkName}} + logging: + driver: "json-file" + options: + max-size: "5m" + max-file: "2" + + mkdocs: + image: squidfunk/mkdocs-material:latest + container_name: {{containerPrefix}}-mkdocs + restart: unless-stopped + volumes: + - ./mkdocs:/docs:rw + - ./assets/images:/docs/assets/images:rw + user: "1000:1000" + environment: + SITE_URL: https://{{domain}} + ADMIN_PORT: "{{ports.admin}}" + ADMIN_URL: https://app.{{domain}} + BASE_DOMAIN: https://{{domain}} + API_URL: https://api.{{domain}} + API_PORT: "{{ports.api}}" +{{#if enableMedia}} + MEDIA_API_PUBLIC_URL: https://media.{{domain}} + MEDIA_API_PORT: "4100" +{{/if}} +{{#if enableGancio}} + GANCIO_URL: http://{{containerPrefix}}-gancio:13120 + GANCIO_PORT: "8092" +{{/if}} + command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload + networks: + - {{networkName}} + +{{#if enableListmonk}} + listmonk-db: + image: postgres:17-alpine + container_name: {{containerPrefix}}-listmonk-db + restart: unless-stopped + environment: + POSTGRES_USER: listmonk + POSTGRES_PASSWORD: {{secrets.listmonkAdminPassword}} + POSTGRES_DB: listmonk + volumes: + - {{containerPrefix}}-listmonk-data:/var/lib/postgresql/data + networks: + - {{networkName}} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U listmonk"] + interval: 10s + timeout: 5s + retries: 6 + + listmonk-app: + image: listmonk/listmonk:latest + container_name: {{containerPrefix}}-listmonk + restart: unless-stopped + depends_on: + listmonk-db: + condition: service_healthy + command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"] + environment: + LISTMONK_app__address: "0.0.0.0:9000" + LISTMONK_db__host: {{containerPrefix}}-listmonk-db + LISTMONK_db__port: "5432" + LISTMONK_db__user: listmonk + LISTMONK_db__password: {{secrets.listmonkAdminPassword}} + LISTMONK_db__database: listmonk + LISTMONK_db__ssl_mode: disable + TZ: Etc/UTC + LISTMONK_ADMIN_USER: admin + LISTMONK_ADMIN_PASSWORD: {{secrets.listmonkAdminPassword}} + volumes: + - ./assets/uploads:/listmonk/uploads:rw + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + listmonk-init: + image: postgres:17-alpine + container_name: {{containerPrefix}}-listmonk-init + depends_on: + listmonk-app: + condition: service_started + restart: "no" + environment: + PGPASSWORD: {{secrets.listmonkAdminPassword}} + LISTMONK_API_USER: v2-api + LISTMONK_API_TOKEN: {{secrets.listmonkApiToken}} + LISTMONK_SMTP_HOST: {{containerPrefix}}-mailhog + LISTMONK_SMTP_PORT: "1025" + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "[listmonk-init] Waiting for Listmonk tables..." + for i in $$(seq 1 30); do + if psql -h {{containerPrefix}}-listmonk-db -U listmonk -d listmonk -c "SELECT 1 FROM users LIMIT 1" >/dev/null 2>&1; then + break + fi + sleep 2 + done + + if [ -n "$$LISTMONK_API_TOKEN" ]; then + echo "[listmonk-init] Upserting API user '$$LISTMONK_API_USER'..." + psql -h {{containerPrefix}}-listmonk-db -U listmonk -d listmonk -q < process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s +{{/if}} + +{{#if enableChat}} + nats-rocketchat: + image: nats:2.11-alpine + container_name: {{containerPrefix}}-nats + restart: unless-stopped + command: --http_port 8222 + networks: + - {{networkName}} + + mongodb-rocketchat: + image: mongo:6.0 + container_name: {{containerPrefix}}-mongodb + restart: unless-stopped + command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] + volumes: + - {{containerPrefix}}-mongodb-data:/data/db + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "try { rs.status().ok } catch(e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'{{containerPrefix}}-mongodb:27017'}]}).ok }"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s + + rocketchat: + image: rocketchat/rocket.chat:7.9.7 + container_name: {{containerPrefix}}-rocketchat + restart: unless-stopped + depends_on: + mongodb-rocketchat: + condition: service_healthy + nats-rocketchat: + condition: service_started + environment: + ROOT_URL: http://chat.{{domain}} + MONGO_URL: mongodb://{{containerPrefix}}-mongodb:27017/rocketchat?replicaSet=rs0 + MONGO_OPLOG_URL: mongodb://{{containerPrefix}}-mongodb:27017/local?replicaSet=rs0 + TRANSPORTER: monolith+nats://{{containerPrefix}}-nats:4222 + PORT: "3000" + ADMIN_USERNAME: rcadmin + ADMIN_NAME: Admin + ADMIN_EMAIL: {{secrets.adminEmail}} + ADMIN_PASS: {{secrets.nocodbAdminPassword}} + CREATE_TOKENS_FOR_USERS: "true" + OVERWRITE_SETTING_Iframe_Integration_send_enable: "true" + OVERWRITE_SETTING_Iframe_Integration_receive_enable: "true" + OVERWRITE_SETTING_Iframe_Integration_receive_origin: http://app.{{domain}},https://app.{{domain}} + volumes: + - {{containerPrefix}}-rocketchat-uploads:/app/uploads + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/info"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s +{{/if}} + + # ─── Pangolin Tunnel ─────────────────────────────────────── + + newt: + image: fosrl/newt:latest + container_name: {{containerPrefix}}-newt + restart: unless-stopped + depends_on: + - nginx + environment: + PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT} + NEWT_ID: ${PANGOLIN_NEWT_ID} + NEWT_SECRET: ${PANGOLIN_NEWT_SECRET} + networks: + - {{networkName}} + + # ─── Always-On Utilities ────────────────────────────────── + + mini-qr: + image: ghcr.io/lyqht/mini-qr:latest + container_name: {{containerPrefix}}-mini-qr + restart: unless-stopped + networks: + - {{networkName}} + + mkdocs-site-server: + image: nginx:alpine + container_name: {{containerPrefix}}-mkdocs-site + restart: unless-stopped + volumes: + - ./mkdocs/site:/usr/share/nginx/html:ro + networks: + - {{networkName}} + +{{#if enableDevTools}} + # ─── Dev Tools ──────────────────────────────────────────── + + code-server: + image: lscr.io/linuxserver/code-server:latest + container_name: {{containerPrefix}}-code-server + restart: unless-stopped + environment: + PASSWORD: {{secrets.nocodbAdminPassword}} + SUDO_PASSWORD: {{secrets.nocodbAdminPassword}} + volumes: + - .:/config/workspace:rw + networks: + - {{networkName}} + + gitea: + image: gitea/gitea:latest + container_name: {{containerPrefix}}-gitea + restart: unless-stopped + depends_on: + v2-postgres: + condition: service_healthy + environment: + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: {{containerPrefix}}-postgres:5432 + GITEA__database__NAME: gitea + GITEA__database__USER: changemaker + GITEA__database__PASSWD: {{secrets.postgresPassword}} + GITEA__server__ROOT_URL: https://git.{{domain}} + GITEA__server__DOMAIN: git.{{domain}} + GITEA__security__INSTALL_LOCK: "true" + volumes: + - {{containerPrefix}}-gitea-data:/data + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/healthz"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + n8n: + image: n8nio/n8n:latest + container_name: {{containerPrefix}}-n8n + restart: unless-stopped + environment: + N8N_ENCRYPTION_KEY: {{secrets.n8nEncryptionKey}} + WEBHOOK_URL: https://n8n.{{domain}} + N8N_HOST: n8n.{{domain}} + N8N_PROTOCOL: https + volumes: + - {{containerPrefix}}-n8n-data:/home/node/.n8n + networks: + - {{networkName}} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"] + interval: 30s + timeout: 5s + retries: 3 + + homepage: + image: ghcr.io/gethomepage/homepage:latest + container_name: {{containerPrefix}}-homepage + restart: unless-stopped + volumes: + - {{containerPrefix}}-homepage-data:/app/config + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - {{networkName}} + + excalidraw: + image: excalidraw/excalidraw:latest + container_name: {{containerPrefix}}-excalidraw + restart: unless-stopped + networks: + - {{networkName}} +{{/if}} + +{{#if enableMonitoring}} + # ─── Monitoring Stack ────────────────────────────────────── + + prometheus: + image: prom/prometheus:latest + container_name: {{containerPrefix}}-prometheus + restart: unless-stopped + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + volumes: + - ./configs/prometheus:/etc/prometheus:ro + - {{containerPrefix}}-prometheus-data:/prometheus + networks: + - {{networkName}} + + grafana: + image: grafana/grafana:latest + container_name: {{containerPrefix}}-grafana + restart: unless-stopped + environment: + GF_SECURITY_ADMIN_PASSWORD: {{secrets.grafanaAdminPassword}} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_SERVER_ROOT_URL: https://grafana.{{domain}} + GF_SECURITY_ALLOW_EMBEDDING: "true" + volumes: + - {{containerPrefix}}-grafana-data:/var/lib/grafana + - ./configs/grafana:/etc/grafana/provisioning + depends_on: + - prometheus + networks: + - {{networkName}} + + alertmanager: + image: prom/alertmanager:latest + container_name: {{containerPrefix}}-alertmanager + restart: unless-stopped + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + volumes: + - ./configs/alertmanager:/etc/alertmanager:ro + - {{containerPrefix}}-alertmanager-data:/alertmanager + networks: + - {{networkName}} +{{/if}} + +# ─── Volumes ────────────────────────────────────────────── + +volumes: + {{containerPrefix}}-postgres-data: + {{containerPrefix}}-redis-data: + {{containerPrefix}}-nocodb-data: +{{#if enableListmonk}} + {{containerPrefix}}-listmonk-data: +{{/if}} +{{#if enableGancio}} + {{containerPrefix}}-gancio-data: +{{/if}} +{{#if enableChat}} + {{containerPrefix}}-mongodb-data: + {{containerPrefix}}-rocketchat-uploads: +{{/if}} +{{#if enableDevTools}} + {{containerPrefix}}-gitea-data: + {{containerPrefix}}-n8n-data: + {{containerPrefix}}-homepage-data: +{{/if}} +{{#if enableMonitoring}} + {{containerPrefix}}-prometheus-data: + {{containerPrefix}}-grafana-data: + {{containerPrefix}}-alertmanager-data: +{{/if}} + +# ─── Networks ───────────────────────────────────────────── + +networks: + {{networkName}}: + driver: bridge diff --git a/changemaker-control-panel/templates/env.hbs b/changemaker-control-panel/templates/env.hbs new file mode 100644 index 00000000..ce064822 --- /dev/null +++ b/changemaker-control-panel/templates/env.hbs @@ -0,0 +1,223 @@ +# ============================================================ +# Changemaker Lite — Instance: {{name}} +# Generated by CCP on {{now}} +# ============================================================ + +# Core +NODE_ENV=production +DOMAIN={{domain}} +USER_ID=1000 +GROUP_ID=1000 +DOCKER_GROUP_ID=984 + +# V2 PostgreSQL +V2_POSTGRES_USER=changemaker +V2_POSTGRES_PASSWORD={{secrets.postgresPassword}} +V2_POSTGRES_DB=changemaker_v2 +V2_POSTGRES_PORT={{ports.postgres}} +DATABASE_URL=postgresql://changemaker:{{secrets.postgresPassword}}@{{containerPrefix}}-postgres:5432/changemaker_v2 + +# Redis +REDIS_PASSWORD={{secrets.redisPassword}} +REDIS_URL=redis://:{{secrets.redisPassword}}@{{containerPrefix}}-redis:6379 + +# JWT Auth +JWT_ACCESS_SECRET={{secrets.jwtAccessSecret}} +JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}} +JWT_ACCESS_EXPIRY=15m +JWT_REFRESH_EXPIRY=7d + +# Encryption +ENCRYPTION_KEY={{secrets.encryptionKey}} + +# Initial Admin +INITIAL_ADMIN_EMAIL={{secrets.adminEmail}} +INITIAL_ADMIN_PASSWORD={{secrets.initialAdminPassword}} + +# API +API_PORT=4000 +PORT=4000 +API_URL=https://api.{{domain}} +CORS_ORIGINS=https://app.{{domain}},http://localhost:{{ports.admin}},http://localhost +ADMIN_URL=https://app.{{domain}} + +# Admin GUI +ADMIN_PORT=3000 + +# Nginx +NGINX_HTTP_PORT={{ports.nginx}} +NGINX_HTTPS_PORT=443 + +# SMTP / Email +{{#if emailTestMode}} +SMTP_HOST={{containerPrefix}}-mailhog +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASS= +EMAIL_TEST_MODE=true +{{else}} +SMTP_HOST={{smtpHost}} +SMTP_PORT={{smtpPort}} +SMTP_USER={{smtpUser}} +SMTP_PASS= +EMAIL_TEST_MODE=false +{{/if}} +SMTP_FROM={{smtpFrom}} +SMTP_FROM_NAME={{name}} +TEST_EMAIL_RECIPIENT={{secrets.adminEmail}} + +# NocoDB +NOCODB_V2_PORT=8080 +NOCODB_URL=http://{{containerPrefix}}-nocodb:8080 +NC_ADMIN_EMAIL={{secrets.adminEmail}} +NC_ADMIN_PASSWORD={{secrets.nocodbAdminPassword}} + +# Listmonk +{{#if enableListmonk}} +LISTMONK_SYNC_ENABLED=true +LISTMONK_URL=http://{{containerPrefix}}-listmonk:9000 +{{else}} +LISTMONK_SYNC_ENABLED=false +LISTMONK_URL= +{{/if}} +LISTMONK_PORT=9000 +LISTMONK_DB_USER=listmonk +LISTMONK_DB_PASSWORD={{secrets.listmonkAdminPassword}} +LISTMONK_DB_NAME=listmonk +LISTMONK_WEB_ADMIN_USER=admin +LISTMONK_WEB_ADMIN_PASSWORD={{secrets.listmonkAdminPassword}} +LISTMONK_API_USER=v2-api +LISTMONK_API_TOKEN={{secrets.listmonkApiToken}} +LISTMONK_ADMIN_USER=v2-api +LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}} +LISTMONK_PROXY_PORT=9002 + +# Media +{{#if enableMedia}} +ENABLE_MEDIA_FEATURES=true +{{else}} +ENABLE_MEDIA_FEATURES=false +{{/if}} +MEDIA_API_PORT=4100 +MEDIA_ROOT=/media/local +MEDIA_UPLOADS=/media/uploads +MAX_UPLOAD_SIZE_GB=10 + +# NAR Data +NAR_DATA_DIR=/data + +# Platform Service URLs (used for health checks) +MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080 +EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80 +HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000 +VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80 + +# Geocoding +MAPBOX_API_KEY= +GOOGLE_MAPS_API_KEY= +GOOGLE_MAPS_ENABLED=false + +# Represent API +REPRESENT_API_URL=https://represent.opennorth.ca + +# Pangolin Tunnel +PANGOLIN_API_URL= +PANGOLIN_API_KEY= +PANGOLIN_ORG_ID= +PANGOLIN_SITE_ID= +PANGOLIN_ENDPOINT= +PANGOLIN_NEWT_ID= +PANGOLIN_NEWT_SECRET= + +# Gancio +{{#if enableGancio}} +GANCIO_SYNC_ENABLED=true +GANCIO_URL=http://{{containerPrefix}}-gancio:13120 +{{else}} +GANCIO_SYNC_ENABLED=false +GANCIO_URL= +{{/if}} +GANCIO_BASE_URL=https://events.{{domain}} +GANCIO_ADMIN_USER=admin +GANCIO_ADMIN_PASSWORD={{secrets.gancioAdminPassword}} +GANCIO_PORT=8092 + +# Chat (Rocket.Chat) +{{#if enableChat}} +ENABLE_CHAT=true +ROCKETCHAT_URL=http://{{containerPrefix}}-rocketchat:3000 +ROCKETCHAT_ADMIN_USER=rcadmin +ROCKETCHAT_ADMIN_PASSWORD={{secrets.nocodbAdminPassword}} +{{else}} +ENABLE_CHAT=false +ROCKETCHAT_URL= +ROCKETCHAT_ADMIN_USER= +ROCKETCHAT_ADMIN_PASSWORD= +{{/if}} + +# Monitoring +GRAFANA_ADMIN_PASSWORD={{secrets.grafanaAdminPassword}} +GRAFANA_ROOT_URL=https://grafana.{{domain}} +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3000 + +# MkDocs +MKDOCS_PORT={{math ports.embed "+" 8}} +CODE_SERVER_PORT={{math ports.embed "+" 7}} +BASE_DOMAIN=https://{{domain}} + +# Gitea +GITEA_URL=http://{{containerPrefix}}-gitea:3000 +GITEA_DB_PASSWD={{secrets.giteaAdminPassword}} +GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}} +GITEA_ROOT_URL=https://git.{{domain}} +GITEA_DOMAIN=git.{{domain}} + +# n8n +N8N_HOST=n8n.{{domain}} +N8N_URL=http://{{containerPrefix}}-n8n:5678 +N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}} +N8N_USER_EMAIL={{secrets.adminEmail}} +N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}} + +# MailHog +MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025 +MAILHOG_SMTP_PORT=1025 +MAILHOG_WEB_PORT=8025 + +# Dev Tools +{{#if enableDevTools}} +ENABLE_DEV_TOOLS=true +{{else}} +ENABLE_DEV_TOOLS=false +{{/if}} + +# Payments +{{#if enablePayments}} +ENABLE_PAYMENTS=true +{{else}} +ENABLE_PAYMENTS=false +{{/if}} + +# Vite (admin build) +VITE_API_URL=http://{{containerPrefix}}-api:4000 +VITE_MKDOCS_URL=http://{{containerPrefix}}-mkdocs:8000 +{{#if enableMedia}} +VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100 +{{/if}} + +# Embed proxy ports (nginx proxy for iframe embedding in admin GUI) +NOCODB_EMBED_PORT={{math ports.embed "+" 0}} +N8N_EMBED_PORT={{math ports.embed "+" 1}} +GITEA_EMBED_PORT={{math ports.embed "+" 2}} +MAILHOG_EMBED_PORT={{math ports.embed "+" 3}} +MINI_QR_EMBED_PORT={{math ports.embed "+" 4}} +EXCALIDRAW_EMBED_PORT={{math ports.embed "+" 5}} +HOMEPAGE_EMBED_PORT={{math ports.embed "+" 6}} +CODE_SERVER_EMBED_PORT={{math ports.embed "+" 7}} +MKDOCS_EMBED_PORT={{math ports.embed "+" 8}} +VAULTWARDEN_EMBED_PORT={{math ports.embed "+" 9}} +ROCKETCHAT_EMBED_PORT={{math ports.embed "+" 10}} +GANCIO_EMBED_PORT={{math ports.embed "+" 11}} +GRAFANA_EMBED_PORT={{math ports.embed "+" 12}} +LISTMONK_EMBED_PORT={{math ports.embed "+" 13}} diff --git a/changemaker-control-panel/templates/nginx/conf.d/api.conf.hbs b/changemaker-control-panel/templates/nginx/conf.d/api.conf.hbs new file mode 100644 index 00000000..3ca189c5 --- /dev/null +++ b/changemaker-control-panel/templates/nginx/conf.d/api.conf.hbs @@ -0,0 +1,2 @@ +# API-specific configuration placeholder +# (Primary API routing is in default.conf) diff --git a/changemaker-control-panel/templates/nginx/conf.d/default.conf.hbs b/changemaker-control-panel/templates/nginx/conf.d/default.conf.hbs new file mode 100644 index 00000000..023ad14c --- /dev/null +++ b/changemaker-control-panel/templates/nginx/conf.d/default.conf.hbs @@ -0,0 +1,81 @@ +# Changemaker Lite Nginx — Instance: {{name}} +# Routes all traffic through single entry point + +server { + listen 80 default_server; + server_name localhost _; + add_header X-Frame-Options "SAMEORIGIN" always; + + # Admin GUI + Public pages (default) + location / { + set $upstream_admin http://{{containerPrefix}}-admin:3000; + proxy_pass $upstream_admin; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + +{{#if enableMedia}} + # Media API admin routes (must come BEFORE /api/ for longest prefix match) + # Rewrites /api/media/* to /api/* on media-api + location /api/media/ { + set $upstream_media http://{{containerPrefix}}-media-api:4100; + rewrite ^/api/media/(.*) /api/$1 break; + proxy_pass $upstream_media; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Large upload support + client_max_body_size 10G; + proxy_read_timeout 3600s; + proxy_connect_timeout 75s; + proxy_request_buffering off; + + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Media API routes rewrite (matches Vite dev proxy behavior) + # Rewrites /media/* to /api/* on media-api (port 4100) + location /media/ { + set $upstream_media_default http://{{containerPrefix}}-media-api:4100; + rewrite ^/media/(.*) /api/$1 break; + proxy_pass $upstream_media_default; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Large upload support + client_max_body_size 10G; + proxy_read_timeout 3600s; + proxy_connect_timeout 75s; + proxy_request_buffering off; + + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +{{/if}} + + # Express API + location /api/ { + set $upstream_api http://{{containerPrefix}}-api:4000; + proxy_pass $upstream_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + } +} diff --git a/changemaker-control-panel/templates/nginx/conf.d/services.conf.hbs b/changemaker-control-panel/templates/nginx/conf.d/services.conf.hbs new file mode 100644 index 00000000..4b9d0349 --- /dev/null +++ b/changemaker-control-panel/templates/nginx/conf.d/services.conf.hbs @@ -0,0 +1,243 @@ +# Changemaker Lite — Instance: {{name}} +# Embed proxy ports for iframe embedding in admin GUI. +# These strip X-Frame-Options and CSP so services can be iframed. +# Internal ports 8881-8894 are mapped to host ports via docker-compose. + +# NocoDB embed proxy (internal 8881) +server { + listen 8881; + location / { + set $upstream_nocodb http://{{containerPrefix}}-nocodb:8080; + proxy_pass $upstream_nocodb; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# n8n embed proxy (internal 8882) +server { + listen 8882; + location / { + set $upstream_n8n http://{{containerPrefix}}-n8n:5678; + proxy_pass $upstream_n8n; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# Gitea embed proxy (internal 8883) +server { + listen 8883; + client_max_body_size 2048M; + location / { + set $upstream_gitea http://{{containerPrefix}}-gitea:3000; + proxy_pass $upstream_gitea; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# MailHog embed proxy (internal 8884) +server { + listen 8884; + location / { + set $upstream_mailhog http://{{containerPrefix}}-mailhog:8025; + proxy_pass $upstream_mailhog; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# Mini QR embed proxy (internal 8885) +server { + listen 8885; + location / { + set $upstream_miniqr http://{{containerPrefix}}-mini-qr:8080; + proxy_pass $upstream_miniqr; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Excalidraw embed proxy (internal 8886) +server { + listen 8886; + location / { + set $upstream_excalidraw http://{{containerPrefix}}-excalidraw:80; + proxy_pass $upstream_excalidraw; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + } +} + +# Homepage embed proxy (internal 8887) +server { + listen 8887; + location / { + set $upstream_homepage http://{{containerPrefix}}-homepage:3000; + proxy_pass $upstream_homepage; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Code Server embed proxy (internal 8888) +server { + listen 8888; + location / { + set $upstream_code http://{{containerPrefix}}-code-server:8080; + proxy_pass $upstream_code; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# MkDocs embed proxy (internal 8889) +server { + listen 8889; + location / { + set $upstream_mkdocs http://{{containerPrefix}}-mkdocs:8000; + proxy_pass $upstream_mkdocs; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# Vaultwarden embed proxy (internal 8890) +server { + listen 8890; + location / { + set $upstream_vaultwarden http://{{containerPrefix}}-vaultwarden:80; + proxy_pass $upstream_vaultwarden; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + } +} + +# Rocket.Chat embed proxy (internal 8891) +{{#if enableChat}} +server { + listen 8891; + location / { + set $upstream_rocketchat http://{{containerPrefix}}-rocketchat:3000; + proxy_pass $upstream_rocketchat; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + client_max_body_size 100m; + } +} +{{/if}} + +# Gancio embed proxy (internal 8892) +{{#if enableGancio}} +server { + listen 8892; + location / { + set $upstream_gancio http://{{containerPrefix}}-gancio:13120; + proxy_pass $upstream_gancio; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +{{/if}} + +# Grafana embed proxy (internal 8893) +{{#if enableMonitoring}} +server { + listen 8893; + location / { + set $upstream_grafana http://{{containerPrefix}}-grafana:3000; + proxy_pass $upstream_grafana; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +{{/if}} + +# Listmonk embed proxy (internal 8894) +{{#if enableListmonk}} +server { + listen 8894; + location / { + set $upstream_listmonk http://{{containerPrefix}}-listmonk:9000; + proxy_pass $upstream_listmonk; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +{{/if}} diff --git a/changemaker-control-panel/templates/nginx/nginx.conf b/changemaker-control-panel/templates/nginx/nginx.conf new file mode 100644 index 00000000..056abe32 --- /dev/null +++ b/changemaker-control-panel/templates/nginx/nginx.conf @@ -0,0 +1,47 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + server_tokens off; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 50m; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + # Security headers (applied globally — X-Frame-Options set per server block) + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Permissions-Policy "geolocation=(self), microphone=(), camera=()" always; + + # Docker internal DNS — enables runtime resolution so nginx starts + # even when optional services are not running + resolver 127.0.0.11 valid=30s; + + include /etc/nginx/conf.d/*.conf; +}