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 ? (
+ }
+ onClick={() => setDrawerOpen(true)}
+ style={{ fontSize: 16 }}
+ />
+ ) : (
+ : }
+ onClick={() => setCollapsed(!collapsed)}
+ style={{ fontSize: 16 }}
+ />
+ )}
+
+
+
} size="small" />
+
{user?.name || user?.email}
+
+
+
+
+
+
+
+
+ );
+}
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={loading}>
+ Re-scan
+
+
+
+ )
+ }
+ >
+ {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={[
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ window.open(`https://app.${instance.domain}`, '_blank');
+ }}
+ />
+ ,
+ instance.status === 'RUNNING' ? (
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ onStop?.(instance.id);
+ }}
+ />
+
+ ) : (instance.status === 'STOPPED' || instance.status === 'ERROR') ? (
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ onStart?.(instance.id);
+ }}
+ />
+
+ ) : (
+
+ }
+ disabled
+ />
+
+ ),
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ navigate(`/app/instances/${instance.id}`);
+ }}
+ />
+ ,
+ ]}
+ >
+
+ {instance.name}
+ {instance.status}
+
+ }
+ description={
+
+ {instance.domain}
+ {healthCheck && (
+
+
+ Services: {healthCheck.healthyServices}/{healthCheck.totalServices}
+
+
+ )}
+
+ {instance.enableMedia && Media}
+ {instance.enableListmonk && Newsletter}
+ {instance.enableGancio && Events}
+ {instance.enableChat && Chat}
+ {instance.enableMonitoring && Monitoring}
+
+
+ }
+ />
+
+ );
+}
diff --git a/changemaker-control-panel/admin/src/components/LogViewer.tsx b/changemaker-control-panel/admin/src/components/LogViewer.tsx
new file mode 100644
index 00000000..6809c529
--- /dev/null
+++ b/changemaker-control-panel/admin/src/components/LogViewer.tsx
@@ -0,0 +1,186 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { Select, Space, Button, message, Spin, Empty } from 'antd';
+import { ReloadOutlined, CopyOutlined, VerticalAlignBottomOutlined, SyncOutlined } from '@ant-design/icons';
+import axios from 'axios';
+import { api } from '@/lib/api';
+
+interface LogViewerProps {
+ instanceId: string;
+ services: string[];
+ initialService?: string;
+}
+
+const TAIL_OPTIONS = [
+ { label: '100 lines', value: 100 },
+ { label: '200 lines', value: 200 },
+ { label: '500 lines', value: 500 },
+ { label: '1000 lines', value: 1000 },
+];
+
+const SINCE_OPTIONS = [
+ { label: '1 hour', value: '1h' },
+ { label: '6 hours', value: '6h' },
+ { label: '24 hours', value: '24h' },
+ { label: 'All time', value: '' },
+];
+
+const AUTO_REFRESH_INTERVAL = 5000;
+
+export default function LogViewer({ instanceId, services, initialService }: LogViewerProps) {
+ const [service, setService] = useState(initialService);
+ const [tail, setTail] = useState(200);
+ const [since, setSince] = useState('1h');
+ const [logs, setLogs] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [autoRefresh, setAutoRefresh] = useState(false);
+ const logRef = useRef(null);
+ const abortRef = useRef(null);
+ const loadingRef = useRef(false);
+
+ useEffect(() => {
+ if (initialService) setService(initialService);
+ }, [initialService]);
+
+ const fetchLogs = useCallback(async () => {
+ // Abort any in-flight request
+ if (abortRef.current) {
+ abortRef.current.abort();
+ }
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ setLoading(true);
+ loadingRef.current = true;
+ try {
+ const params = new URLSearchParams();
+ if (service) params.set('service', service);
+ params.set('tail', tail.toString());
+ if (since) params.set('since', since);
+
+ const { data } = await api.get(`/instances/${instanceId}/logs?${params}`, {
+ signal: controller.signal,
+ });
+ setLogs(data.data || '');
+ } catch (err) {
+ // Ignore aborted requests — they're expected during rapid dropdown changes
+ if (axios.isCancel(err)) return;
+ setLogs('Failed to load logs');
+ } finally {
+ setLoading(false);
+ loadingRef.current = false;
+ }
+ }, [instanceId, service, tail, since]);
+
+ // Fetch when parameters change
+ useEffect(() => {
+ fetchLogs();
+ // Cleanup: abort on unmount or before next effect run
+ return () => {
+ if (abortRef.current) {
+ abortRef.current.abort();
+ }
+ };
+ }, [fetchLogs]);
+
+ // Auto-refresh interval
+ useEffect(() => {
+ if (!autoRefresh) return;
+ const id = setInterval(() => {
+ // Skip if a manual fetch is already in progress
+ if (!loadingRef.current) {
+ fetchLogs();
+ }
+ }, AUTO_REFRESH_INTERVAL);
+ return () => clearInterval(id);
+ }, [autoRefresh, fetchLogs]);
+
+ useEffect(() => {
+ if (autoScroll && logRef.current) {
+ logRef.current.scrollTop = logRef.current.scrollHeight;
+ }
+ }, [logs, autoScroll]);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(logs);
+ message.success('Logs copied to clipboard');
+ } catch {
+ message.error('Failed to copy');
+ }
+ };
+
+ return (
+
+
+
+
+ {loading && !logs ? (
+
+
+
+ ) : !logs ? (
+
+ ) : (
+
+ {logs}
+
+ )}
+
+ );
+}
diff --git a/changemaker-control-panel/admin/src/components/ProtectedRoute.tsx b/changemaker-control-panel/admin/src/components/ProtectedRoute.tsx
new file mode 100644
index 00000000..14d7b64e
--- /dev/null
+++ b/changemaker-control-panel/admin/src/components/ProtectedRoute.tsx
@@ -0,0 +1,22 @@
+import { Spin } from 'antd';
+import { Navigate } from 'react-router-dom';
+import { useAuthStore } from '@/stores/auth.store';
+
+export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated, isLoading } = useAuthStore();
+
+ // Wait for hydration to complete before deciding on redirect
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/changemaker-control-panel/admin/src/components/ServiceHealthGrid.tsx b/changemaker-control-panel/admin/src/components/ServiceHealthGrid.tsx
new file mode 100644
index 00000000..a75e78f2
--- /dev/null
+++ b/changemaker-control-panel/admin/src/components/ServiceHealthGrid.tsx
@@ -0,0 +1,138 @@
+import { Table, Tag, Button, Space, Tooltip, Empty } from 'antd';
+import { ReloadOutlined, FileTextOutlined } from '@ant-design/icons';
+
+export interface ServiceInfo {
+ name: string;
+ service: string;
+ status: string;
+ state: string;
+ health: string;
+ ports: string;
+ createdAt: string;
+ exitCode: number;
+}
+
+interface ServiceHealthGridProps {
+ services: ServiceInfo[];
+ loading?: boolean;
+ onRestart?: (service: string) => void;
+ onViewLogs?: (service: string) => void;
+}
+
+const stateColors: Record = {
+ running: 'green',
+ exited: 'red',
+ restarting: 'orange',
+ paused: 'gold',
+ dead: 'red',
+ created: 'blue',
+};
+
+const healthColors: Record = {
+ healthy: 'green',
+ unhealthy: 'red',
+ starting: 'processing',
+};
+
+export default function ServiceHealthGrid({
+ services,
+ loading,
+ onRestart,
+ onViewLogs,
+}: ServiceHealthGridProps) {
+ if (!loading && services.length === 0) {
+ return ;
+ }
+
+ const columns = [
+ {
+ title: 'Service',
+ dataIndex: 'service',
+ key: 'service',
+ render: (name: string) => {name},
+ },
+ {
+ title: 'Container',
+ dataIndex: 'name',
+ key: 'name',
+ render: (name: string) => (
+ {name}
+ ),
+ },
+ {
+ title: 'State',
+ dataIndex: 'state',
+ key: 'state',
+ render: (state: string) => (
+ {state.toUpperCase()}
+ ),
+ },
+ {
+ title: 'Health',
+ dataIndex: 'health',
+ key: 'health',
+ render: (health: string) => {
+ if (!health || health === 'none' || health === '') {
+ return N/A;
+ }
+ return {health};
+ },
+ },
+ {
+ title: 'Status',
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: string) => (
+ {status}
+ ),
+ },
+ {
+ title: 'Ports',
+ dataIndex: 'ports',
+ key: 'ports',
+ render: (ports: string) => (
+ {ports || '-'}
+ ),
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ width: 100,
+ render: (_: unknown, record: ServiceInfo) => (
+
+ {onRestart && (
+
+ }
+ onClick={() => onRestart(record.service)}
+ />
+
+ )}
+ {onViewLogs && (
+
+ }
+ onClick={() => onViewLogs(record.service)}
+ />
+
+ )}
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
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
+
+ } onClick={fetchLogs}>
+ Refresh
+
+
+
+
+
+ { setActionFilter(v); setPage(1); }}
+ options={AUDIT_ACTIONS.map((a) => ({ label: a, value: a }))}
+ />
+ { 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
+
+
+ }
+ onClick={handleBackupAll}
+ loading={backingUpAll}
+ >
+ Backup All Running
+
+ } onClick={fetchBackups}>
+ Refresh
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { setInstanceFilter(v); setPage(1); }}
+ options={instances.map((i) => ({ label: i.name, value: i.id }))}
+ />
+
+
+
{ 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' && (
+ }
+ size="small"
+ type="text"
+ onClick={() => handleDownload(record.id)}
+ />
+ )}
+ handleDelete(record.id)}
+ >
+ } size="small" type="text" danger />
+
+
+ ),
+ },
+ ]}
+ />
+
+ );
+}
diff --git a/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx b/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx
new file mode 100644
index 00000000..c14d4f90
--- /dev/null
+++ b/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx
@@ -0,0 +1,489 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ Typography,
+ Steps,
+ Form,
+ Input,
+ Switch,
+ Button,
+ Card,
+ Space,
+ Descriptions,
+ Tag,
+ Alert,
+ InputNumber,
+ Result,
+ Progress,
+} from 'antd';
+import { SyncOutlined } from '@ant-design/icons';
+import { useNavigate } from 'react-router-dom';
+import { api } from '@/lib/api';
+import type { Instance } from '@/types/api';
+
+interface WizardData {
+ name: string;
+ slug: string;
+ domain: string;
+ adminEmail: string;
+ enableMedia: boolean;
+ enableChat: boolean;
+ enableGancio: boolean;
+ enableListmonk: boolean;
+ enableMonitoring: boolean;
+ enableDevTools: boolean;
+ enablePayments: boolean;
+ smtpHost: string;
+ smtpPort: number;
+ smtpUser: string;
+ smtpFrom: string;
+ emailTestMode: boolean;
+ enablePangolin: boolean;
+ notes: string;
+}
+
+const defaultData: WizardData = {
+ name: '',
+ slug: '',
+ domain: '',
+ adminEmail: '',
+ enableMedia: false,
+ enableChat: false,
+ enableGancio: false,
+ enableListmonk: false,
+ enableMonitoring: false,
+ enableDevTools: false,
+ enablePayments: false,
+ smtpHost: '',
+ smtpPort: 587,
+ smtpUser: '',
+ smtpFrom: '',
+ emailTestMode: true,
+ enablePangolin: false,
+ notes: '',
+};
+
+/** Parse provisioning step from statusMessage like "Step 5/13: Rendering configuration files" */
+function parseProvisioningStep(msg: string | null | undefined): { current: number; total: number; description: string } | null {
+ if (!msg) return null;
+ const match = msg.match(/Step (\d+)\/(\d+): (.+)/);
+ if (match) {
+ return { current: parseInt(match[1], 10), total: parseInt(match[2], 10), description: match[3] };
+ }
+ return null;
+}
+
+export default function CreateWizardPage() {
+ const navigate = useNavigate();
+ const [current, setCurrent] = useState(0);
+ const [data, setData] = useState(defaultData);
+ const [creating, setCreating] = useState(false);
+ const [createdId, setCreatedId] = useState(null);
+ const [error, setError] = useState(null);
+ const [form] = Form.useForm();
+
+ // Provisioning tracking
+ const [provisioningInstance, setProvisioningInstance] = useState(null);
+
+ const update = (fields: Partial) => setData((prev) => ({ ...prev, ...fields }));
+
+ const autoSlug = (name: string) => {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+ };
+
+ const handleCreate = async () => {
+ setCreating(true);
+ setError(null);
+ try {
+ const { data: result } = await api.post('/instances', data);
+ setCreatedId(result.data.id);
+ setProvisioningInstance(result.data);
+ } catch (err: unknown) {
+ const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response
+ ?.data?.error;
+ setError(resp?.message || 'Failed to create instance');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ // Poll for provisioning progress
+ const pollStatus = useCallback(async () => {
+ if (!createdId) return;
+ try {
+ const { data: result } = await api.get(`/instances/${createdId}`);
+ setProvisioningInstance(result.data);
+ } catch {
+ // Silently fail — will retry on next poll
+ }
+ }, [createdId]);
+
+ useEffect(() => {
+ if (!createdId) return;
+ if (provisioningInstance?.status === 'RUNNING' || provisioningInstance?.status === 'STOPPED') return;
+ // Don't poll if error (user needs to decide what to do)
+ if (provisioningInstance?.status === 'ERROR') return;
+
+ const interval = setInterval(pollStatus, 3_000);
+ return () => clearInterval(interval);
+ }, [createdId, provisioningInstance?.status, pollStatus]);
+
+ const handleRetryProvision = async () => {
+ if (!createdId) return;
+ try {
+ await api.post(`/instances/${createdId}/provision`);
+ setProvisioningInstance((prev) =>
+ prev ? { ...prev, status: 'PROVISIONING', statusMessage: 'Retrying provisioning...' } : prev
+ );
+ } catch {
+ setError('Failed to retry provisioning');
+ }
+ };
+
+ const steps = [
+ {
+ title: 'Basic Info',
+ content: (
+
+ {
+ update({ name: e.target.value, slug: autoSlug(e.target.value) });
+ }}
+ placeholder="Better Edmonton"
+ />
+
+
+ update({ slug: e.target.value })}
+ placeholder="better-edmonton"
+ />
+
+
+ update({ domain: e.target.value })}
+ placeholder="betteredmonton.org"
+ />
+
+
+ update({ adminEmail: e.target.value })}
+ placeholder="admin@betteredmonton.org"
+ type="email"
+ />
+
+
+ update({ notes: e.target.value })}
+ rows={3}
+ placeholder="Optional notes about this instance..."
+ />
+
+
+ ),
+ },
+ {
+ title: 'Features',
+ content: (
+
+
+
+ update({ enableMedia: v })} />
+ Video library, uploads, gallery, analytics
+
+
+
+
+ update({ enableListmonk: v })} />
+ Email newsletters, subscriber management
+
+
+
+
+ update({ enableGancio: v })} />
+ Community events calendar, shift sync
+
+
+
+
+ update({ enableChat: v })} />
+ Team communication, channels
+
+
+
+
+ update({ enableMonitoring: v })} />
+ Prometheus, Grafana, alerts
+
+
+
+
+ update({ enableDevTools: v })} />
+ Code Server, Gitea, n8n, Homepage, Excalidraw
+
+
+
+
+ update({ enablePayments: v })} />
+ Vaultwarden (secrets vault, future)
+
+
+
+ ),
+ },
+ {
+ title: 'Email',
+ content: (
+
+
+ update({ emailTestMode: v })}
+ checkedChildren="Test Mode (MailHog)"
+ unCheckedChildren="Real SMTP"
+ />
+
+
+ {!data.emailTestMode && (
+ <>
+
+ update({ smtpHost: e.target.value })}
+ placeholder="smtp.sendgrid.net"
+ />
+
+
+ update({ smtpPort: v || 587 })}
+ style={{ width: 120 }}
+ />
+
+
+ update({ smtpUser: e.target.value })}
+ placeholder="apikey"
+ />
+
+
+ update({ smtpFrom: e.target.value })}
+ placeholder="noreply@betteredmonton.org"
+ />
+
+ >
+ )}
+
+ ),
+ },
+ {
+ title: 'Tunnel',
+ content: (
+
+
+
+
+ update({ enablePangolin: v })} />
+ Auto-create site and resources on Pangolin
+
+
+ {data.enablePangolin && (
+
+ app.{data.domain || '...'} — Admin + Public
+ api.{data.domain || '...'} — API
+ docs.{data.domain || '...'} — Documentation
+ {data.enableMedia && media.{data.domain || '...'} — Media API}
+ {data.enableListmonk && listmonk.{data.domain || '...'} — Newsletters}
+ {data.enableGancio && events.{data.domain || '...'} — Events}
+
+ }
+ type="info"
+ />
+ )}
+
+ ),
+ },
+ {
+ title: 'Review',
+ content: (
+
+ {error && setError(null)} />}
+
+ {data.name}
+ {data.slug}
+ {data.domain}
+ {data.adminEmail}
+
+ {data.emailTestMode ? 'Test (MailHog)' : `SMTP: ${data.smtpHost}:${data.smtpPort}`}
+
+
+
+ {data.enableMedia && Media}
+ {data.enableListmonk && Newsletter}
+ {data.enableGancio && Events}
+ {data.enableChat && Chat}
+ {data.enableMonitoring && Monitoring}
+ {data.enableDevTools && Dev Tools}
+ {data.enablePayments && Payments}
+ {!data.enableMedia && !data.enableListmonk && !data.enableGancio && !data.enableChat && !data.enableMonitoring && !data.enableDevTools && !data.enablePayments && (
+ Core only
+ )}
+
+
+
+ {data.enablePangolin ? 'Enabled' : 'Disabled'}
+
+
+
+
+ ),
+ },
+ ];
+
+ // ─── Provisioning Progress View ──────────────────────────────────
+ if (createdId && provisioningInstance) {
+ const inst = provisioningInstance;
+ const stepInfo = parseProvisioningStep(inst.statusMessage);
+ const isDone = inst.status === 'RUNNING';
+ const isError = inst.status === 'ERROR';
+
+ if (isDone) {
+ return (
+ navigate(`/app/instances/${createdId}`)}>
+ View Instance
+ ,
+ ,
+ ,
+ ]}
+ />
+ );
+ }
+
+ 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`}
+
+ )}
+
+
}
+ onClick={() => navigate('/app/instances/new')}
+ >
+ Create Instance
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ 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={
+ }
+ size="small"
+ onClick={handleHealthCheck}
+ loading={checkingHealth}
+ >
+ Check Now
+
+ }
+ >
+ {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 = (
+
+
+ } onClick={fetchServices} loading={servicesLoading}>
+ Refresh
+
+
+
+
+ );
+
+ const logsTab = (
+
+ );
+
+ const backupsTab = (
+
+ {isRegistered && (
+
+ )}
+
+
+ {backups.length} backup{backups.length !== 1 ? 's' : ''}
+
+ }
+ type="primary"
+ onClick={handleCreateBackup}
+ loading={creatingBackup}
+ disabled={instance.status !== 'RUNNING' || isRegistered}
+ >
+ Create Backup
+
+
+ {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' && (
+ }
+ size="small"
+ type="text"
+ onClick={() => handleDownloadBackup(record.id)}
+ />
+ )}
+ handleDeleteBackup(record.id)}
+ >
+ } size="small" type="text" danger />
+
+
+ ),
+ },
+ ]}
+ />
+ ) : (
+
+ )}
+
+ );
+
+ const canReconfigure = !isRegistered && (instance.status === 'RUNNING' || instance.status === 'STOPPED');
+
+ const featuresTab = (
+
+ {isRegistered && (
+
+ )}
+ {!isRegistered && (
+
+ )}
+
+
+
+
+
+ Media Manager
+
+ Video library, uploads, gallery, analytics
+
+
setFeatureFlags((f) => ({ ...f, enableMedia: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Newsletter (Listmonk)
+
+ Email newsletters, subscriber management
+
+
setFeatureFlags((f) => ({ ...f, enableListmonk: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Events (Gancio)
+
+ Community events calendar, shift sync
+
+
setFeatureFlags((f) => ({ ...f, enableGancio: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Chat (Rocket.Chat)
+
+ Team communication, channels
+
+
setFeatureFlags((f) => ({ ...f, enableChat: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+
+
+
+
+ Monitoring
+
+ Prometheus, Grafana, Alertmanager
+
+
setFeatureFlags((f) => ({ ...f, enableMonitoring: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Dev Tools
+
+ Code Server, Gitea, n8n, Homepage, Excalidraw
+
+
setFeatureFlags((f) => ({ ...f, enableDevTools: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Payments
+
+ Vaultwarden (secrets vault, future)
+
+
setFeatureFlags((f) => ({ ...f, enablePayments: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ {canReconfigure && (
+
+
+
+ }
+ loading={reconfiguring}
+ disabled={!hasFeatureChanges}
+ >
+ Apply Changes
+
+
+
+ )}
+
+ );
+
+ 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.
+
+ }
+ onClick={() => setPasswordModalOpen(true)}
+ >
+ Unlock Credentials
+
+
+ ) : (
+ <>
+
+
+ {secretsLoading && (
+
+
+
+ )}
+
+ {secretsError && (
+
+ Retry
+
+ }
+ />
+ )}
+
+ {secrets && !secretsLoading && (
+ <>
+ {isRegisteredSecrets ? (
+
+
+
+
+ {secrets.initialAdminEmail || 'Not set'}
+
+
+
+
+
+ }
+ size="small"
+ type="text"
+ onClick={() => {
+ if (secrets.initialAdminPassword) {
+ navigator.clipboard.writeText(secrets.initialAdminPassword);
+ message.success('Password copied');
+ }
+ }}
+ disabled={!secrets.initialAdminPassword}
+ />
+
+
+
+
+ ) : (
+ : }
+ size="small"
+ onClick={() => setRevealAll((v) => !v)}
+ >
+ {revealAll ? 'Hide All' : 'Reveal All'}
+
+ }
+ >
+
+ {secretEntries.map(([key, value]) => (
+
+
+ {revealAll ? (
+
+ {String(value ?? 'null')}
+
+ ) : (
+
+ )}
+ }
+ size="small"
+ type="text"
+ onClick={() => {
+ if (value) {
+ navigator.clipboard.writeText(String(value));
+ message.success(`${key} copied`);
+ }
+ }}
+ disabled={!value}
+ />
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ {
+ setPasswordModalOpen(false);
+ passwordForm.resetFields();
+ if (!passwordVerified) setActiveTab('overview');
+ }}
+ footer={null}
+ destroyOnHidden
+ >
+
+ For security, please re-enter your password to access instance credentials.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ return (
+
+
+
} onClick={() => navigate('/app/instances')} />
+
+ {instance.name}
+
+
{instance.status}
+ {isRegistered &&
External}
+
+
+ {canStart && (
+ }
+ type="primary"
+ onClick={() => handleAction('start', 'Start')}
+ loading={actionLoading === 'start'}
+ >
+ Start
+
+ )}
+ {canStop && (
+ handleAction('stop', 'Stop')}
+ >
+ }
+ danger
+ loading={actionLoading === 'stop'}
+ >
+ Stop
+
+
+ )}
+ {canRestart && (
+ }
+ onClick={() => handleAction('restart', 'Restart')}
+ loading={actionLoading === 'restart'}
+ >
+ Restart
+
+ )}
+ }
+ onClick={() => window.open(`https://app.${instance.domain}`, '_blank')}
+ >
+ Open Site
+
+ } onClick={fetchInstance}>
+ Refresh
+
+
+
+
+
, 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 (
+
+ }
+ onClick={() => navigate(`/app/instances/${record.id}`)}
+ />
+ {canStop ? (
+ handleStop(record.id)}
+ >
+ }
+ loading={isLoading}
+ />
+
+ ) : (
+ }
+ disabled={!canStart}
+ loading={isLoading}
+ onClick={() => handleStart(record.id)}
+ />
+ )}
+ handleDelete(record.id)}
+ okText={record.isRegistered ? 'Unregister' : 'Delete'}
+ okType="danger"
+ >
+ } />
+
+
+ );
+ },
+ },
+ ];
+
+ return (
+
+
+
+ Instances
+
+
+ }
+ onClick={() => setDiscoverOpen(true)}
+ >
+ Discover Instances
+
+ }
+ onClick={() => navigate('/app/instances/register')}
+ >
+ Register Existing
+
+ }
+ onClick={() => navigate('/app/instances/new')}
+ >
+ Create Instance
+
+
+
+
+
+ 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;
+}