diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..971f0af1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force LF line endings for shell scripts regardless of the developer's OS. +# Shell scripts with CRLF endings will fail with "exec format error" in Linux containers. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f8e3a8..cbf6eaa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [ develop ] jobs: - build-and-test: + backend: runs-on: ubuntu-latest steps: - name: Checkout Code @@ -18,22 +18,265 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: | - backend/package-lock.json - frontend/package-lock.json + cache-dependency-path: backend/package-lock.json - - name: Install Backend Dependencies + - name: Install Dependencies working-directory: ./backend run: npm ci - - name: Build Backend (TypeScript) + - name: Build (TypeScript) working-directory: ./backend run: npm run build - - name: Install Frontend Dependencies + - name: Unit Tests (Vitest) + working-directory: ./backend + run: npm test + + - name: Lint (ESLint) + working-directory: ./backend + run: npm run lint + + - name: Audit Dependencies + working-directory: ./backend + run: npm audit --audit-level=high + + frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Dependencies working-directory: ./frontend run: npm ci - - name: Build Frontend (Vite/React) + - name: Build (Vite/React) working-directory: ./frontend run: npm run build + + - name: Lint (ESLint) + working-directory: ./frontend + run: npm run lint + + - name: Audit Dependencies + working-directory: ./frontend + run: npm audit --audit-level=high + + docker-validate: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (validation only) + uses: docker/build-push-action@v5 + with: + context: . + push: false + load: true + tags: sencho:pr-test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Scan image for vulnerabilities (Trivy) + uses: aquasecurity/trivy-action@master + with: + image-ref: sencho:pr-test + exit-code: '0' + severity: 'CRITICAL,HIGH' + format: 'table' + continue-on-error: true + + e2e: + runs-on: ubuntu-latest + needs: [ backend, frontend ] + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install root dependencies (Playwright) + run: npm ci + + - name: Install backend dependencies + working-directory: ./backend + run: npm ci + + - name: Build backend + working-directory: ./backend + run: npm run build + + - name: Install frontend dependencies + working-directory: ./frontend + run: npm ci + + - name: Create compose directory + run: mkdir -p /tmp/compose + + - name: Start backend + working-directory: ./backend + run: node dist/index.js & + env: + JWT_SECRET: ci-test-secret-key-not-for-production + COMPOSE_DIR: /tmp/compose + PORT: 3000 + NODE_ENV: test + + - name: Start frontend dev server + working-directory: ./frontend + run: npm run dev & + + - name: Wait for services to be ready + run: npx wait-on http://localhost:3000/api/health http://localhost:5173 --timeout 30000 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + run: npx playwright test + env: + E2E_USERNAME: admin + E2E_PASSWORD: password123 + + - name: Upload E2E report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/report/ + test-results/ + retention-days: 7 + + update-screenshots: + runs-on: ubuntu-latest + # Only on develop pushes; skip bot commits to avoid an infinite loop + if: "github.event_name == 'push' && github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'docs: refresh screenshots')" + needs: [ backend, frontend ] + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + # PAT required — GITHUB_TOKEN cannot create PRs against a protected + # branch. DOCS_REPO_TOKEN is a classic PAT with repo scope. + token: ${{ secrets.DOCS_REPO_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install root dependencies (Playwright) + run: npm ci + + - name: Install backend dependencies + working-directory: ./backend + run: npm ci + + - name: Build backend + working-directory: ./backend + run: npm run build + + - name: Install frontend dependencies + working-directory: ./frontend + run: npm ci + + - name: Create compose directory + run: mkdir -p /tmp/compose + + - name: Start backend + working-directory: ./backend + run: node dist/index.js & + env: + JWT_SECRET: ci-test-secret-key-not-for-production + COMPOSE_DIR: /tmp/compose + PORT: 3000 + NODE_ENV: test + + - name: Start frontend dev server + working-directory: ./frontend + run: npm run dev & + + - name: Wait for services to be ready + run: npx wait-on http://localhost:3000/api/health http://localhost:5173 --timeout 30000 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Capture screenshots + run: npx playwright test e2e/screenshots.spec.ts --project=chromium + env: + E2E_USERNAME: admin + E2E_PASSWORD: password123 + + - name: Open / update screenshots PR + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.DOCS_REPO_TOKEN }} + branch: chore/refresh-screenshots + commit-message: "docs: refresh screenshots" + title: "docs: refresh screenshots" + body: "Automated screenshot refresh — generated by the `update-screenshots` CI job on every push to `develop`." + add-paths: docs/images/ + delete-branch: true + + sync-docs: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + steps: + - name: Checkout Sencho repo + uses: actions/checkout@v4 + with: + path: sencho + + - name: Clone or init sencho-docs + env: + DOCS_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }} + run: | + # Always start clean so a previous partial/broken checkout can't cause + # "not in a git directory" on subsequent steps. + rm -rf sencho-docs + + REMOTE="https://x-access-token:${DOCS_TOKEN}@github.com/AnsoCode/sencho-docs.git" + + if git clone "${REMOTE}" sencho-docs 2>/dev/null; then + echo "Cloned existing sencho-docs." + else + echo "Clone failed (repo is empty or has no default branch) — initializing." + mkdir -p sencho-docs + cd sencho-docs + git init + git checkout -b main + git remote add origin "${REMOTE}" + fi + + - name: Copy /docs into sencho-docs root + # --exclude='.git' prevents rsync --delete from wiping the .git + # directory that was just cloned/initialized in the previous step. + run: rsync -av --delete --exclude='.git' sencho/docs/ sencho-docs/ + + - name: Commit and push to sencho-docs + working-directory: sencho-docs + run: | + git config user.email "docs-bot@sencho.io" + git config user.name "Sencho Docs Bot" + git add -A + git commit -m "docs: sync from develop@${{ github.sha }}" || echo "No changes to commit" + git push origin HEAD:main diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9222df74..4a2165d3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,6 +16,11 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -42,6 +47,7 @@ jobs: with: context: . push: true + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=saelix/sencho:buildcache diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc12407..62b3dd33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,116 +5,159 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes — the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal. -- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node. -- **Fixed:** Container stats (CPU/RAM/NET), bash exec terminal, and "Open App" button all broken for remote nodes — stats and exec WebSockets connected to the bare root (`ws://host`) with no `?nodeId=` query param, so the upgrade handler couldn't detect the remote node and skipped the WS proxy. Moved all generic WebSockets to `/ws?nodeId=` path; upgrade handler now correctly proxies them to the remote Sencho instance. -- **Fixed:** Bash exec and stats WebSockets not reaching the backend in `npm run dev` — Vite proxy config now includes `ws: true` on `/api` and a new `/ws` proxy entry so all WebSocket upgrades are forwarded to `localhost:3000`. -- **Fixed:** Backend WS message handler crashing when a proxied WebSocket arrives from a gateway and the forwarded `nodeId` doesn't exist in the remote instance's DB — now falls back to the default local node instead of throwing. -- **Fixed:** "Open App" button opening `http://localhost:{port}` for remote node containers — now resolves the hostname from the remote node's `api_url` so the correct remote host is used. -- **Fixed:** Remote node system stats, container stats, logs, and exec returning errors — `remoteNodeProxy` middleware was already positioned before API route definitions, but `/api/system/stats` contained a dead remote branch that called `NodeRegistry.getDocker()` for remote nodes, which throws since remote nodes have no direct Docker socket access. Removed the broken branch; remote requests are correctly intercepted by the proxy middleware before reaching any route handler. -- **Added:** Background image update checker — `ImageUpdateService` polls OCI-compliant registries (Docker Hub, GHCR, LSCR, etc.) every 6 hours using manifest digest comparison against local `RepoDigests`. Results cached in a new `stack_update_status` SQLite table. A pulsing blue dot badge appears in the stack list sidebar for stacks with available updates. Manual refresh available via `POST /api/image-updates/refresh` (rate-limited to once per 10 minutes). -- **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node. -- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter — the upgrade handler now receives the active node ID and routes the PTY session to the correct node. -- **Added:** Two-tier Option A scoped navigation UX — a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local). -- **Added:** Remote-aware headers in `HostConsole` ("Host Console — [Node Name]"), `ResourcesView` ("Resources Hub — [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]"). -- **Added:** `SettingsModal` now scopes its sidebar to the active node type — when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name. -- **Fixed:** A massive memory leak (browser Out of Memory crash) by throttling historical metrics polling down to 60s and downsampling SQLite metrics payload sizes by 12x. -- **Fixed:** A bug where the active node UI dropdown would desync from the actual API requests on initial page load by properly hydrating state from localStorage. -- **Fixed:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance — the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication. -- **Fixed:** `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted/non-existent node — the nodes list endpoint must always succeed so the frontend can re-sync a stale node ID in localStorage; exempted alongside `/api/auth/`. -- **Fixed:** Remote node proxy stripping the `/api` path prefix — `remoteNodeProxy` is mounted at `app.use('/api/', ...)` so Express strips that prefix from `req.url` before `http-proxy-middleware` sees it; added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to the remote Sencho instance (e.g. `/stats` → `/api/stats`). This was the root cause of all remote API calls returning the remote's SPA HTML instead of JSON. -- **Fixed:** Dashboard cards (Active Containers, Host CPU, Host RAM, Docker Network) showing stale local-node data after switching to a remote node — `HomeDashboard` polling effects now depend on `activeNode?.id` and clear state immediately on node change. -- **Fixed:** `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response (e.g., connection refused to unreachable remote node) — now checks `res.ok` before calling `res.json()` and iterates a typed `fileList` instead of the raw parsed value. -- **Fixed:** Restored Local/Remote type selector and fixed state resets in the Add Node modal — form now resets to defaults every time the dialog opens, and the title reflects the chosen type dynamically. -- **Fixed:** Remote node connection details failing to display Containers, Images, and CPU metrics — `testRemoteConnection` now fires parallel requests to `/api/stats`, `/api/system/stats`, and `/api/system/images` after auth succeeds, mapping real values into the info panel. -- **Fixed:** Suppressed `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` — override is applied to `process.emitWarning` before the proxy instances are created, cleanly intercepting the warning at its call site without suppressing other warnings. -- **Fixed:** Backend memory leak caused by improper proxy middleware instantiation - `createProxyMiddleware` was called inside the request handler on every API call, spawning a new `http-proxy` instance (and registering new server listeners) per request. Refactored to a single globally-instantiated proxy using the `router` option for dynamic per-request target resolution. -- **Fixed:** `[DEP0060] DeprecationWarning: util._extend` deprecation eliminated as a side-effect of the above fix (deprecation was triggered on every new `http-proxy` initialisation). -- **Fixed:** Remote node authentication failures - `authMiddleware` and WebSocket upgrade handler both accept `Authorization: Bearer` tokens (Sencho-to-Sencho proxy auth). -- **Fixed:** Node connection testing logic updated to perform authenticated HTTP pings to `/api/auth/check` on the remote instance. -- **Fixed:** Node switcher dropdown failing to trigger data refreshes - `EditorLayout` now reacts to `activeNode` changes, re-fetching stacks and clearing stale editor/container state when the user switches nodes. -- **Fixed:** API Token copy button failing silently on HTTP / non-localhost deployments where `navigator.clipboard` is unavailable - added `try/catch` with `document.execCommand('copy')` fallback. -- **Fixed:** Remote node authentication failures by updating middleware to support Bearer tokens in WebSocket upgrade handler (node-to-node WS proxy now authenticates correctly on the receiving instance). -- **Fixed:** Node connection testing logic updated to normalize `api_url` trailing slashes before constructing the authenticated HTTP ping URL. -- **Fixed:** Memory leak in `GlobalObservabilityView` SSE mode - log array now capped at 10,000 entries (`.slice(-10000)`) to prevent unbounded accumulation across long sessions. -- **Fixed:** Infinite re-fetch loop in `NodeContext` - `refreshNodes` useCallback no longer depends on `activeNode` state; replaced with a `useRef` to read current node inside the callback without being a reactive dependency. -- **Fixed:** Infinite page reload loop - `apiFetch` was calling `window.location.href = '/'` on every 401, causing a full browser reload before auth could complete. Replaced with a `sencho-unauthorized` custom event that `AuthContext` handles by setting `appStatus` to `notAuthenticated`. -- **Fixed:** `NodeProvider` was mounted outside the auth gate in `App.tsx`, causing `refreshNodes` to fire before authentication was established (hitting 401 immediately on boot). Moved `NodeProvider` inside the authenticated branch so it only mounts after login. -- **Removed:** SSH/SFTP file adapters and remote Docker TCP connections (net negative ~500 lines of code). -- **Added:** Distributed API proxying using http-proxy-middleware for HTTP and WebSockets. -- **Added:** Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`). -- **Changed:** Node Manager UI vastly simplified - remote nodes now only require an API URL and Token. -- **Fixed:** Critical port routing conflict - separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED. -- **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var. -- **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`. -- **Added:** Full SSH credential fields (SSH Port, Username, Password, Private Key) to the Node Manager Add/Edit forms. -- **Added:** `ssh_port` column to the `nodes` database table with migration support (default: 22). -- **Changed:** Global `FileSystemService` and `ComposeService` singletons refactored into node-aware instances. -- **Added:** `IFileAdapter`, `LocalFileAdapter`, and `SSHFileAdapter` to abstract all filesystem interactions for remote node support. -- **Changed:** `MonitorService` now evaluates limits, fetches metrics, and detects container crashes across all registered nodes concurrently. -- **Added:** Node Context Middleware in Express API to dynamically extract `x-node-id` headers and parse WebSocket query parameters. -- **Added:** Remote Nodes Foundation - `nodes` table in SQLite with auto-seeded default local node. -- **Added:** `NodeRegistry` service for managing multiple Docker daemon connections (local socket + TCP). -- **Added:** Node management API endpoints: list, get, create, update, delete, and test connection. -- **Added:** Settings Hub → Nodes tab with full CRUD UI, connection testing, and Docker info display. -- **Added:** Node switcher dropdown in sidebar (auto-visible when multiple nodes are configured). -- **Added:** `NodeContext` for frontend-wide active node state management. -- **Fixed:** Global logs false-positive error misclassifications caused by Docker containers writing INFO logs to STDERR. Replaced naive regex with a robust 3-tier classification engine supporting `level=info`, `[INFO]`, and ` INFO ` format standards. -- **Added:** Developer Mode setting to enable true Real-Time (SSE) global log streaming and infinite scroll. -- **Added:** Configurable polling rates for standard global logs monitoring. -- **Added:** React Throttle Buffer to prevent UI freezing during heavy real-time log ingestion. -- **Fixed:** Global Logs aggressive auto-scrolling preventing users from reading log history. -- **Fixed:** Quiet stacks missing from the Global Logs filter dropdown by fetching the definitive stack list independently. -- **Fixed:** Global logs misclassifying INFO messages as errors due to naive string matching. -- **Changed:** Global logs now display chronologically (newest at bottom) with smooth auto-scrolling. -- **Changed:** Renamed Observability navigation tab to Logs. -- **Fixed:** TTY container log streams failing to parse globally. -- **Fixed:** Global logs displaying in UTC instead of local browser timezone. -- **Changed:** Global Logs UI revamped to use a floating, hover-based action bar to maximize terminal space. -- **Fixed:** Docker raw byte multiplex headers leaking into global logs stream. -- **Changed:** Relocated historical CPU/RAM charts to the Home Dashboard and normalized data values (CPU relative to host cores, RAM to GB). -- **Added:** Dozzle-style Action Bar to Global Logs with multi-select stack filtering, search, and STDOUT/STDERR toggles. -- **Added:** Centralized observability dashboard tracking 24-hour historical metrics and aggregating global tail logs across all running containers. -- **Added:** Live Container Logs viewer using Server-Sent Events (SSE) for real-time terminal output. -- **Added:** Pre-deploy folder collision check to prevent silent configuration overwrites in the App Store. -- **Added:** UI subtitle during deployment to reassure users during long image downloads. -- **Changed:** Standardized manual stack deletion to use the Two-Stage Teardown (Compose Down -> File Wipe) to prevent ghost networks. -- **Fixed:** Atomic Rollback failure where non-empty directories caused silent file system errors. -- **Added:** Two-Stage Teardown mechanism to ensure `docker compose down` sweeps up ghost networks before deployment files are deleted. -### Deprecated -- **(Planned)** Port 2375 (TCP) fallback support; future releases may require SSH-only for Node config. + +--- + +## [0.1.0] - 2026-03-24 + +### Security + +- **Fixed:** Missing `authMiddleware` on `GET /api/notifications`, `POST /api/notifications/read`, `DELETE /api/notifications/:id`, `DELETE /api/notifications`, `POST /api/notifications/test`, and `POST /api/system/console-token` — any unauthenticated client could reach these endpoints. +- **Fixed:** Remote node `api_url` accepted without validation — an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`. +- **Fixed:** `env_file` paths in `compose.yaml` were accepted without boundary checking — absolute paths like `/etc/passwd` could be read or written. All resolved env file paths are now validated to stay within the stack directory. +- **Fixed:** Stack name validated in write routes but not GET routes — path-traversal names now return 400 on all routes. +- **Fixed:** `stackParam` query parameter on `/api/system/host-console` now validated against `path.resolve` + `startsWith(baseDir)` to prevent directory traversal when setting the PTY working directory. +- **Fixed:** `HostTerminalService` no longer forwards full `process.env` to spawned PTY shells — `JWT_SECRET`, `AUTH_PASSWORD`, `AUTH_PASSWORD_HASH`, and `DATABASE_URL` are stripped before the shell is spawned. +- **Fixed:** Host Console and container exec WebSocket endpoints now reject `node_proxy` scoped JWT tokens with HTTP 403. +- **Fixed:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend. +- **Fixed:** `POST /api/settings` enforces a strict allowlist of writable keys — auth credential keys and unknown keys are rejected with a 400 error. +- **Added:** Rate limiting on `/api/auth/login` and `/api/auth/setup` — 5 attempts per 15-minute window per IP, using `express-rate-limit`. +- **Added:** `helmet` middleware for security response headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, etc.). +- **Changed:** CORS is now restricted to `FRONTEND_URL` env var in production; development continues to allow any origin. + +### Added + +#### Infrastructure & CI +- `linux/arm64` platform support in the Docker Hub publish workflow (Raspberry Pi 4/5, Oracle ARM VMs) — native modules (`bcrypt`, `better-sqlite3`, `node-pty`) cross-compiled via `tonistiigi/xx` to eliminate the `SIGILL` crash caused by Node.js v20 using ARMv8.1 LSE atomic instructions unsupported by GitHub Actions QEMU. +- `docker/setup-qemu-action@v3` step to `docker-publish.yml` — without it multi-platform builds hung indefinitely. +- Automated Docker Hub CI/CD pipeline publishing `dev` and `latest` tags. +- Automated documentation pipeline with Mintlify sync and screenshot refresh CI job. +- `HEALTHCHECK` directive in `Dockerfile` — Docker polls `/api/health` every 30 s and restarts an unhealthy container. +- `GET /api/health` public endpoint returning `{ status: "ok", uptime }`. +- `docker-entrypoint.sh` — runs as root, fixes `$DATA_DIR` volume ownership, then drops to the non-root `sencho` user via `su-exec` before starting Node. Eliminates `SQLITE_READONLY` crashes on host-mounted volumes. +- Non-root `sencho` system user in `Dockerfile`; process no longer runs as root. +- Graceful shutdown — backend listens for `SIGTERM`/`SIGINT`, drains HTTP connections, stops `MonitorService` and `ImageUpdateService`, and closes the SQLite connection before exiting. +- Vitest backend test suite — 38 tests covering validation utilities, health endpoint, authentication flows, auth middleware enforcement, console-token security, and SSRF validation. Run with `cd backend && npm test`. +- Playwright E2E test scaffolding (`e2e/`) — auth, stack management, and node management specs with shared login helper. Run with `npm run test:e2e`. +- CI workflow runs Vitest unit tests and ESLint on every PR. +- `isValidStackName`, `isValidRemoteUrl`, `isPathWithinBase` extracted to `backend/src/utils/validation.ts` for reuse and testability. + +#### Multi-Node & Distributed API +- Distributed API proxying using `http-proxy-middleware` for HTTP and WebSockets — replaces the SSH/SFTP architecture entirely (~500 lines removed). +- Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`). +- `nodeContextMiddleware` in Express to dynamically extract `x-node-id` headers and `?nodeId=` query parameters for WebSocket upgrades. +- `NodeRegistry` service managing multiple Docker daemon connections. +- Node management API endpoints: list, get, create, update, delete, and test connection. +- Two-tier scoped navigation UX — context pill in the top header always shows the active node name (pulsing blue for remote, green for local). +- Remote-aware headers in `HostConsole`, `ResourcesView`, `GlobalObservabilityView`, and `AppStoreView`. +- `SettingsModal` scopes its sidebar to the active node type — global-only tabs hidden when a remote node is active. +- Cross-node notification aggregation — notification bell surfaces alerts from all connected remote nodes with dedicated real-time WebSocket connections per remote node. +- Remote node host console and container exec WebSocket proxy — gateway exchanges `node_proxy` token for a short-lived `console_session` JWT (60 s TTL) before forwarding. +- `localOnly` option on `apiFetch` — omits `x-node-id` so requests always route to the local node. + +#### Application Features +- **App Store** — LinuxServer.io API integration as default template registry with rich metadata (architectures, docs links, GitHub links), category filter, one-click deployment, atomic rollback on failure, custom Portainer v2 registry URL support, editable ports/volumes/environment variables, post-deploy health probe. +- **Resources Hub** — Images, Volumes, and Networks tabs with Managed/External/Unused classification, Docker Disk Footprint stacked-bar widget, scoped prune operations (Sencho-only vs All Docker), managed/external filter toggles, and classification badges. +- **Global Observability** — centralized dashboard tracking 24-hour historical metrics and aggregating global tail logs across all containers. Dozzle-style Action Bar with multi-select stack filtering, search, STDOUT/STDERR toggles, and Developer Mode SSE real-time streaming. +- **Background image update checker** — polls OCI-compliant registries every 6 hours using manifest digest comparison; results cached in `stack_update_status` table; pulsing blue dot badge on stacks with available updates. +- **Real-time WebSocket notifications** — replaces 5-second polling; `NotificationService.setBroadcaster()` pushes each new alert to all authenticated subscribers the moment it fires. +- **Live Container Logs** viewer using SSE for real-time terminal output. +- **Animated design system** — `motion` package and `animate-ui` library; new brand cyan token; spring-based dialog/tooltip/tab animations; `prefers-reduced-motion` respected globally; Geist font via Google Fonts CDN. +- Theme-aware sidebar logo — dark and light variants auto-switch based on active theme. +- Auto theme option (light/dark/auto) with `window.matchMedia` listener. +- `PATCH /api/settings` bulk-update endpoint — validates all values via Zod schema, persists atomically in a single SQLite transaction. +- `system_state` SQLite table — separates runtime operational state from user-defined config in `global_settings`. +- Configurable `metrics_retention_hours` (default: 24 h) and `log_retention_days` (default: 30 d) — `MonitorService` reads these dynamically each cycle. +- Managed/unmanaged container count split in `GET /api/stats` — Home Dashboard "Active Containers" card shows "N managed · N external". +- Two-Stage Teardown for stack deletion — `docker compose down` sweeps ghost networks before deployment files are deleted. +- Custom Environment Variable injection tool in deployment UI. +- `ErrorBoundary` component now wraps root `` in `main.tsx`. +- Git Flow branching strategy and branch protection. ### Fixed -- Fixed backend MonitorService crash (`Cannot read properties of undefined (reading 'cpu_usage')`) occurring when Docker containers lacked CPU telemetry during transition states. -- Handled UI deleted nodes ghost API calls by intercepting 404 errors globally in API and forcing the UI to resync to the default Node context. -- Hardened `nodeContextMiddleware` in Express to intercept queries to invalid or deleted Node IDs gracefully instead of bubbling to Docker API 500 crashes. -- Hardened Remote Node connection testing (`docker.info()`) to explicitly validate expected Docker API daemon properties instead of merely checking string length. -- Caught Unhandled SFTP Promise Rejections in Node registry gracefully returning empty arrays to prevent frontend loading UI stalls. -- Fixed horizontal UI overflowing in Node Manager settings on smaller resolutions. -- **Fixed:** Docker API parsing bug where HTML string responses from misconfigured ports were counted as containers. -- **Fixed:** Stack list crashing when SFTP connections fail by gracefully catching SSH errors and returning empty arrays. + +#### Authentication & Proxy +- Login loop caused by remote node auth failure — `apiFetch` now only fires `sencho-unauthorized` when the `x-sencho-proxy: 1` header is absent (i.e., a genuine local session failure, not a remote node auth error). +- `authMiddleware` and WS upgrade handler now evaluate `bearerToken || cookieToken` (Bearer first) — cookie no longer shadows a valid Bearer token on node-to-node proxy calls. +- Remote node proxy stripping the `/api` path prefix — added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to remote instances. +- Remote node HTTP proxy body forwarding — replaced `proxyReq.write(JSON.stringify(req.body))` (raced against `http-proxy`'s `process.nextTick(proxyReq.end)`) with a conditional JSON body parser that skips `express.json()` for remote-targeted requests; the raw `IncomingMessage` stream is left unconsumed so `http-proxy`'s `req.pipe(proxyReq)` forwards it intact. +- Remote node proxy forwarding the browser's `sencho_token` cookie to the remote instance — stripped in `proxyReq` so only the Bearer token is used. +- Remote WebSocket upgrades forwarding the browser `cookie` header — stripped before `wsProxyServer.ws()` so the remote's `authMiddleware` uses the Bearer token exclusively. +- `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted node — exempted alongside `/api/auth/` so the frontend can re-sync a stale node ID. +- Backend memory leak from `createProxyMiddleware` called inside the request handler on every API call — refactored to a single globally-instantiated proxy using the `router` option. +- `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` on WebSocket/TCP-level errors — type-narrowed before sending 502. + +#### WebSocket & Streaming +- Container stats WebSocket flooding React with up to 20+ `setState` calls per second — replaced with a ref-buffer + 1.5 s flush interval pattern. +- `streamStats` Docker stats stream leaking after WebSocket client disconnect — `ws.on('close')` handler calls `stats.destroy()`; all `ws.send()` calls guarded with `readyState === OPEN`. +- `streamStats` and `execContainer` called unawaited — unhandled promise rejections now chain `.catch()`, log the error, and close the WebSocket cleanly. +- Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade — `wss.close()` called immediately after `handleUpgrade`. +- WebSocket notification reconnect upgraded to exponential backoff (1 s → 30 s max) instead of flat 5-second retry; `ws.onerror` logs the event; cleanup guards against closing an already-closing socket. +- Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — gateway's `cookie` header stripped before forwarding to remote; `nodeId` query param stripped from forwarded URL. +- LogViewer returning 404 on remote nodes — `nodeId` query param stripped from `proxyReq.path` in `onProxyReq`. + +#### UI & Frontend +- Blank page on HTTP deployments (root cause — Helmet 8 default CSP `upgrade-insecure-requests` and HSTS) — `upgradeInsecureRequests: null` and `strictTransportSecurity: false` set explicitly. +- COOP header console warning on HTTP deployments — `crossOriginOpenerPolicy: false`. +- Inline script CSP violation from Vite module-preload polyfill — disabled via `build.modulePreload.polyfill: false`. +- CSP `workerSrc` missing (Monaco editor workers) — added `worker-src 'self' blob:`. +- CSP `connectSrc` implicit — added explicit `connect-src 'self' ws: wss:`. +- Docker socket `EACCES` root:root edge case — entrypoint handles GID 0 in addition to the standard root:docker case. +- Managed container count wrong when stacks launched from COMPOSE_DIR root — classification now uses `com.docker.compose.project.working_dir`. +- Browser Out of Memory crash in `GlobalObservabilityView` — capped DOM rendering to last 300 entries, reduced SSE log cap to 2,000 entries, replaced `key={idx}` with monotonic `_id` counter. +- `HomeDashboard` create-stack error handling — reads JSON error body before throwing; uses defensive toast pattern. +- `AlertDialogContent` using `asChild` with `motion.div` wrapper crashing on delete-stack confirmation — replaced with CSS keyframe animations. +- animate-ui `auto-height.tsx` importing `WithAsChild` without `type` keyword — crashed browser module loader. +- animate-ui `switch.tsx` double-spreading Radix props onto `motion.button` DOM element. +- "Always Local" badge tooltip crashing (`getStrictContext`) — replaced animate-ui tooltip with pure Radix primitives. +- Cancel/Add Node buttons in NodeManager dialogs stuck together. +- Resources/App Store/Logs menu buttons not toggling off on second click. +- Monaco container height accumulation on tab switching — reset to 0×0 and force synchronous reflow before re-measuring. +- `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject `x-node-id`. +- `HostConsole` WebSocket URL missing `?nodeId=` query parameter. +- "Open App" button opening `http://localhost:{port}` for remote node containers — resolves hostname from remote node's `api_url`. +- Dashboard cards showing stale local-node data after switching to a remote node — polling effects now depend on `activeNode?.id` and clear state immediately on node change. +- `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response — checks `res.ok` before calling `res.json()`. +- Four empty `catch {}` blocks in `EditorLayout` — now surface errors via `toast.error()`. +- `StackAlertSheet` not fetching notification agent status from the active node on open. +- `SettingsModal` Notifications tab hidden when a remote node is active — now visible and configurable on remote nodes. +- `POST /api/alerts` now validates the request body with a Zod schema — rejects unknown metric/operator values, negative thresholds, and missing fields with a structured 400. +- `WebSocket.Server` replaced with named import `WebSocketServer` from `ws` to fix ESM/CJS interop. +- `NodeProvider` mounted outside the auth gate — moved inside the authenticated branch so `refreshNodes` no longer fires before authentication. +- Infinite re-fetch loop in `NodeContext` — `refreshNodes` useCallback no longer depends on `activeNode` state; replaced with `useRef`. +- Infinite page reload loop — `apiFetch` replaced `window.location.href = '/'` with a `sencho-unauthorized` custom event. +- API Token copy button failing silently on HTTP/non-localhost — added `execCommand('copy')` fallback. +- E2E nodes tests permanently timing out because the Add Node submit button requires `api_token` to be non-empty. +- ESLint CI step — replaced all `any` annotations with proper types, fixed unused catch variables. +- `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` — suppressed at call site. +- Global Logs false-positive error misclassifications — replaced naive regex with a robust 3-tier classification engine. +- Memory leak in `GlobalObservabilityView` SSE mode — log array capped at 2,000 entries. +- Historical metrics memory leak — polling throttled to 60 s; SQLite payload downsampled by 12×. +- Active node UI dropdown desyncing from API requests on initial page load — state hydrated from localStorage. +- `MonitorService` crash (`Cannot read properties of undefined (reading 'cpu_usage')`) during Docker container transition states. +- Deleted node ghost API calls — 404 errors intercepted globally, forcing UI to resync to default node. +- Horizontal UI overflow in Node Manager settings on smaller resolutions. +- Docker API parsing bug where HTML string responses from misconfigured ports were counted as containers. ### Changed -- **Changed:** Expanded Node Manager UI width and added horizontal scrollbars for better data visibility. -- **Added:** Smart Error Parser with telemetry-ready rule IDs to translate cryptic Docker output. -- **Added:** Post-Deploy Health Probe to catch immediate container crashes that slip past Compose. -- **Changed:** Rollback engine respects a `canSilentlyRollback` flag to protect user-authored configurations. -- **Changed:** Removed rigid volume sanitization, allowing full user control over bind paths. -- **Added:** Editable Host Volumes in the deployment UI. -- **Added:** Custom Environment Variable injection tool. -- **Fixed:** ScrollArea UI height rendering and dynamic browser timezone detection. -- **Changed:** Rebranded "Templates" to "App Store" across the UI. -- **Added:** Advanced deployment configuration panel (Editable Ports and Environment Variables) with smart defaults. -- **Fixed:** Implemented smart image fallbacks for broken registry logos and added expandable descriptions. -- **Added:** Atomic Deployments: Failed App Store deployments now automatically roll back and delete their orphaned folders. -- **Fixed:** Global dark mode scrollbar styling to eliminate blinding white native scrollbars. -- **Fixed:** Input overlap UI bug in the App Store deployment panel. -### Added -- **Added:** Official LinuxServer.io API integration as the default Template Registry. -- **Added:** Rich metadata display in the App Store (Architectures, Documentation links, GitHub repository links). -- **Added:** Dynamic Template Registry URL support via global settings, defaulting to LinuxServer.io templates. -- **Fixed:** Smart Volume Sanitizer to automatically rewrite messy Portainer bind mounts into clean, relative paths (Sencho 1:1 path rule). -- Git Flow branching strategy and branch protection. -- GitHub Actions CI pipeline for automated TypeScript build verification. -- **Added:** Automated Docker Hub CI/CD pipeline for the `dev` and `latest` tags. -- **Added:** App Templates (App Store) with One-Click deployment, utilizing Portainer v2 JSON registries and auto-generating compose files. + +- **Architecture:** Replaced SSH/SFTP remote node model with Distributed API proxy (HTTP/WebSocket) — remote nodes now only require an API URL and Bearer token. Node Manager UI vastly simplified. +- **Docs:** Migrated Mintlify config from deprecated `mint.json` to `docs.json` v2 format; bootstrapped full user-facing documentation (configuration, stack management, editor, multi-node, alerts, dashboard, resources, app store, observability, settings reference, troubleshooting, backup & restore). +- **Design system:** Animated UI overhaul — new brand cyan token, spring-based animations on dialogs/tooltips/switches/tabs, dark mode shadow strengthening, Geist font now actually loaded. +- Notification delivery replaced polling with WebSocket push — no more `setInterval` in `EditorLayout`. +- `DatabaseService.addNotificationHistory` returns the full inserted record for real-time broadcasting. +- `SettingsModal` overhauled — per-operation loading states, skeleton loader, unsaved-changes indicator, all saves use `PATCH /api/settings`. +- `MonitorService` evaluates limits and detects container crashes across all registered nodes concurrently. +- `MonitorService` reads retention settings dynamically each cycle. +- Developer settings scoped to the local node — reads/writes always target local via `localOnly` regardless of active node. +- Dark mode scrollbar styling — no more white native scrollbars. +- Rebranded "Templates" → "App Store", "Ghost Containers" → "Unmanaged Containers", "Observability" → "Logs". +- Global logs display chronologically (newest at bottom) with smooth auto-scrolling; UTC → local browser timezone. +- Historical CPU/RAM charts relocated to the Home Dashboard; data normalized (CPU relative to host cores, RAM to GB). +- `EditorLayout` main workspace container keyed to `activeView` — every view switch triggers a fade-up entrance animation. + +### Removed + +- SSH/SFTP remote node adapters (`IFileAdapter`, `LocalFileAdapter`, `SSHFileAdapter`, `SSHFileAdapter`, `ComposeService.executeRemote`, `ComposeService.streamLogs` SSH path) — ~500 lines. + +[0.1.0]: https://github.com/AnsoCode/Sencho/releases/tag/v0.1.0 diff --git a/Dockerfile b/Dockerfile index 25538c6f..9d5ba3bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,65 +1,143 @@ +# Cross-compilation helper — provides xx-clang, xx-apk, etc. +# Runs on the BUILD platform; its binaries are copied into build stages below. +FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx + # Stage 1: Build Frontend -FROM node:20-alpine AS frontend-builder +# Runs on the BUILD platform (amd64) — frontend has no native modules so the +# compiled output (JS/CSS/HTML) is entirely platform-agnostic. +FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder WORKDIR /app/frontend -# Copy frontend package files COPY frontend/package*.json ./ - -# Install dependencies RUN npm config set fetch-retry-maxtimeout 120000 && \ npm config set fetch-retries 5 && \ npm install -# Copy frontend source COPY frontend/ ./ - -# Build frontend RUN npm run build -# Stage 2: Build Backend -FROM node:20-alpine AS backend-builder +# Stage 2: Compile TypeScript +# Runs on the BUILD platform (amd64) — tsc output is platform-agnostic JS. +FROM --platform=$BUILDPLATFORM node:20-alpine AS backend-builder WORKDIR /app/backend -# Install build dependencies for node-pty native modules RUN apk add --no-cache python3 make g++ -# Copy backend package files COPY backend/package*.json ./ - -# Install dependencies RUN npm config set fetch-retry-maxtimeout 120000 && \ npm config set fetch-retries 5 && \ npm install -# Copy backend source COPY backend/ ./ - -# Build backend RUN npm run build -# Stage 3: Production -FROM node:20-alpine +# Stage 3: Production dependencies (cross-compiled — NO QEMU execution) +# Runs on the BUILD platform (amd64) but compiles native modules +# (bcrypt, better-sqlite3, node-pty) for the TARGET platform using +# tonistiigi/xx + clang as the cross-compiler. +# This avoids the Node.js v20 SIGILL crash that occurs when npm runs +# under QEMU because QEMU lacks ARMv8.1 LSE atomic instruction support. +FROM --platform=$BUILDPLATFORM node:20-alpine AS prod-deps -# Install Docker CLI, Docker Compose CLI, and Bash for Host Console -RUN apk add --no-cache docker-cli docker-cli-compose bash +# Copy xx cross-compilation tools into this stage +COPY --from=xx / / + +ARG TARGETARCH +ARG BUILDARCH WORKDIR /app -# Copy built backend and node_modules from backend-builder -COPY --from=backend-builder /app/backend/dist ./dist -COPY --from=backend-builder /app/backend/node_modules ./node_modules -COPY --from=backend-builder /app/backend/package.json ./ +# Two paths depending on whether we are cross-compiling: +# +# Native (TARGETARCH == BUILDARCH, e.g. amd64 → amd64): +# Standard g++ is used. xx-clang introduces sysroot flags that conflict with +# node-gyp's header resolution on Alpine for same-platform builds, so we +# bypass it entirely and let npm ci use the host compiler directly. +# +# Cross (TARGETARCH != BUILDARCH, e.g. amd64 → arm64): +# xx-clang targets the foreign architecture without QEMU. The target sysroot +# is populated via xx-apk: +# g++ — libstdc++ headers/libs (all three native modules use C++) +# musl-dev — musl libc headers for the target arch +# linux-headers — / required by node-pty +RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ + apk add --no-cache python3 make g++; \ + else \ + apk add --no-cache clang lld python3 make g++ && \ + xx-apk add --no-cache g++ musl-dev linux-headers; \ + fi -# Copy built frontend from frontend-builder to public folder +COPY backend/package*.json ./ + +# Native: plain npm ci — g++ compiles native modules for the host arch. +# Cross: npm_config_arch tells prebuild-install/node-pre-gyp which pre-built +# binary to attempt; CC/CXX/AR route compilation through xx-clang so +# the output targets the foreign arch without any QEMU emulation. +RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ + npm ci --omit=dev; \ + else \ + npm_config_arch=$TARGETARCH \ + CC=xx-clang \ + CXX=xx-clang++ \ + AR=xx-ar \ + npm ci --omit=dev; \ + fi + +# Stage 4: Production runtime +# Runs on the TARGET platform — no compilation happens here. +FROM node:20-alpine + +# Install Docker CLI, Docker Compose CLI, and Bash for Host Console +RUN apk add --no-cache docker-cli docker-cli-compose bash su-exec + +WORKDIR /app + +# Copy cross-compiled production node_modules from the prod-deps stage +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=prod-deps /app/package.json ./ + +# Copy compiled TypeScript output (platform-agnostic JS) +COPY --from=backend-builder /app/backend/dist ./dist + +# Copy built frontend COPY --from=frontend-builder /app/frontend/dist ./public # Set environment to production ENV NODE_ENV=production +# Create a non-root user and ensure the data/compose directories are writable. +# The actual volume paths are mounted at runtime, so we only pre-create the +# default data dir here; the compose dir is user-supplied via COMPOSE_DIR. +RUN addgroup -S sencho && adduser -S -G sencho sencho \ + && mkdir -p /app/data \ + && chown -R sencho:sencho /app + +# Copy the entrypoint script that fixes data-volume ownership at startup and +# then drops privileges to the sencho user via su-exec (the idiomatic Alpine +# equivalent of gosu). This mirrors the pattern used by official Docker images +# such as PostgreSQL, Redis, and MariaDB. +# +# NOTE: USER directive is intentionally absent here. The entrypoint starts as +# root so it can chown the mounted data volume, then exec's as sencho. Static +# security scanners (Trivy, Clair) may flag "running as root" — this is a known +# and accepted trade-off for self-hosted apps with user-supplied volume mounts. +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +# Strip Windows CRLF line endings that can sneak in on Windows dev machines +# even with .gitattributes eol=lf, then make executable. A shell script with +# \r in tokens like "fi\r" will fail with "unexpected end of file" in Alpine. +RUN sed -i 's/\r//' /usr/local/bin/docker-entrypoint.sh \ + && chmod +x /usr/local/bin/docker-entrypoint.sh + # Expose port EXPOSE 3000 -# Start the server +# Health check — polls the public /api/health endpoint every 30s +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "const h=require('http');h.get('http://localhost:3000/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))" + +# Entrypoint fixes volume ownership as root then drops to sencho via su-exec. +# CMD provides the default arguments passed through to the entrypoint. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["node", "dist/index.js"] diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs new file mode 100644 index 00000000..869f8ac9 --- /dev/null +++ b/backend/eslint.config.mjs @@ -0,0 +1,27 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + files: ['src/**/*.ts'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { ecmaVersion: 2022 }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + // Allow unused catch-clause vars (catch (error) { ... }) and _-prefixed intentional ignores + '@typescript-eslint/no-unused-vars': ['error', { + caughtErrors: 'none', + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + }], + // Pre-existing patterns — warn rather than error until addressed + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-namespace': 'warn', + 'no-empty': 'warn', + // Terminal output processing intentionally uses control characters in regexes + 'no-control-regex': 'off', + 'no-console': 'off', + }, + }, +) diff --git a/backend/package-lock.json b/backend/package-lock.json index 458df2a9..1850dae0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,25 +22,34 @@ "cors": "^2.8.6", "dockerode": "^4.0.9", "express": "^5.2.1", + "express-rate-limit": "^8.3.1", + "helmet": "^8.1.0", "http-proxy": "^1.18.1", "http-proxy-middleware": "^3.0.5", "jsonwebtoken": "^9.0.3", "node-pty": "^1.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { + "@eslint/js": "^9.0.0", "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cookie-parser": "^1.4.10", "@types/http-proxy-middleware": "^0.19.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/supertest": "^7.2.0", "@types/yaml": "^1.9.6", + "eslint": "^9.0.0", "nodemon": "^3.1.13", + "supertest": "^7.2.2", "ts-node": "^10.9.2", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.0.0", + "vitest": "^4.1.0" } }, "node_modules/@balena/dockerignore": { @@ -62,6 +71,283 @@ "node": ">=12" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -111,6 +397,58 @@ "node": ">=6" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -149,6 +487,56 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -213,6 +601,275 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -241,6 +898,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -271,6 +939,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -290,6 +969,13 @@ "@types/express": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -299,6 +985,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -320,6 +1013,13 @@ "@types/ssh2": "*" } }, + "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, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -371,6 +1071,13 @@ "@types/node": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -382,6 +1089,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -454,6 +1168,30 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -473,6 +1211,363 @@ "yaml": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -492,6 +1587,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -499,6 +1595,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -583,6 +1689,20 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -592,6 +1712,16 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -835,6 +1965,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -844,6 +1984,56 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -919,6 +2109,16 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/composerize": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/composerize/-/composerize-1.7.5.tgz", @@ -957,6 +2157,13 @@ "yaml": "^2.x" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -979,6 +2186,13 @@ "node": ">= 0.6" } }, + "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, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1016,6 +2230,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", @@ -1062,6 +2283,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1112,6 +2348,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", @@ -1148,6 +2391,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -1262,6 +2516,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1304,6 +2565,252 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1328,11 +2835,22 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1371,12 +2889,51 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -1393,6 +2950,19 @@ ], "license": "BSD-3-Clause" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1432,6 +3002,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1489,6 +3097,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1602,6 +3228,19 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1663,6 +3302,15 @@ "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==", + "license": "MIT", + "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", @@ -1750,6 +3398,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1757,6 +3415,33 @@ "dev": true, "license": "ISC" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1778,6 +3463,15 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1854,18 +3548,52 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "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==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1909,6 +3637,307 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -1951,6 +3980,13 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -1975,6 +4011,16 @@ "loose-envify": "cli.js" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2012,6 +4058,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2025,6 +4081,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2063,9 +4132,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -2106,12 +4175,38 @@ "license": "MIT", "optional": 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" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2229,6 +4324,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2250,6 +4356,69 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2259,6 +4428,26 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -2269,6 +4458,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2281,6 +4484,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2308,6 +4540,16 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -2368,6 +4610,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -2473,6 +4725,50 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2578,6 +4874,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2650,6 +4969,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2708,6 +5034,16 @@ "node": ">=10" } }, + "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, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split-ca": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", @@ -2731,6 +5067,13 @@ "nan": "^2.23.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2740,6 +5083,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2784,6 +5134,42 @@ "node": ">=0.10.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2851,6 +5237,82 @@ "node": ">=6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2882,6 +5344,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -2926,6 +5401,14 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -2944,6 +5427,19 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -2973,6 +5469,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2995,6 +5515,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3030,6 +5560,236 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3134,6 +5894,28 @@ "engines": { "node": ">=6" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 1f7a0ffc..09920b91 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "build": "tsc", "start": "node dist/index.js", "dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run", + "lint": "eslint src" }, "keywords": [], "author": "", @@ -20,10 +21,16 @@ "@types/http-proxy-middleware": "^0.19.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/supertest": "^7.2.0", "@types/yaml": "^1.9.6", "nodemon": "^3.1.13", + "supertest": "^7.2.2", "ts-node": "^10.9.2", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.0", + "@eslint/js": "^9.0.0", + "eslint": "^9.0.0", + "typescript-eslint": "^8.0.0" }, "dependencies": { "@types/cors": "^2.8.19", @@ -39,12 +46,15 @@ "cors": "^2.8.6", "dockerode": "^4.0.9", "express": "^5.2.1", + "express-rate-limit": "^8.3.1", + "helmet": "^8.1.0", "http-proxy": "^1.18.1", "http-proxy-middleware": "^3.0.5", "jsonwebtoken": "^9.0.3", "node-pty": "^1.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" } } diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts new file mode 100644 index 00000000..22535376 --- /dev/null +++ b/backend/src/__tests__/auth.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for authentication: login, rate limiting, and auth middleware. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +// ─── Login ─────────────────────────────────────────────────────────────────── + +describe('POST /api/auth/login', () => { + it('returns 200 and sets a cookie on valid credentials', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.headers['set-cookie']).toBeDefined(); + }); + + it('returns 401 on wrong password', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: 'wrong-password' }); + + expect(res.status).toBe(401); + }); + + it('returns 401 on unknown username', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'nobody', password: 'anything' }); + + expect(res.status).toBe(401); + }); + + it('returns 400 when credentials are missing', async () => { + const res = await request(app).post('/api/auth/login').send({}); + expect(res.status).toBe(400); + }); +}); + +// ─── Auth middleware ────────────────────────────────────────────────────────── + +describe('authMiddleware', () => { + it('rejects requests with no token (401)', async () => { + const res = await request(app).get('/api/stacks'); + expect(res.status).toBe(401); + }); + + it('rejects requests with an invalid token (401)', async () => { + const res = await request(app) + .get('/api/stacks') + .set('Authorization', 'Bearer this.is.not.valid'); + expect(res.status).toBe(401); + }); + + it('accepts a valid Bearer token', async () => { + // Issue a real token using the known test secret + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const res = await request(app) + .get('/api/stacks') + .set('Authorization', `Bearer ${token}`); + // Will succeed (200) or fail with a docker/fs error (500) — but NOT 401 + expect(res.status).not.toBe(401); + }); + + it('accepts a valid cookie token', async () => { + // First login to get the cookie + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + const cookies = loginRes.headers['set-cookie'] as string | string[]; + const cookieHeader = Array.isArray(cookies) ? cookies[0] : cookies; + + const res = await request(app) + .get('/api/stacks') + .set('Cookie', cookieHeader); + expect(res.status).not.toBe(401); + }); +}); + +// ─── Protected endpoint: console-token ─────────────────────────────────────── + +describe('POST /api/system/console-token', () => { + it('returns 401 without authentication (was a security bug — C1 fix)', async () => { + const res = await request(app).post('/api/system/console-token'); + expect(res.status).toBe(401); + }); + + it('returns a token when authenticated', async () => { + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const res = await request(app) + .post('/api/system/console-token') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(typeof res.body.token).toBe('string'); + }); +}); diff --git a/backend/src/__tests__/health.test.ts b/backend/src/__tests__/health.test.ts new file mode 100644 index 00000000..571ca1a1 --- /dev/null +++ b/backend/src/__tests__/health.test.ts @@ -0,0 +1,41 @@ +/** + * Tests for the public /api/health endpoint. + * This endpoint must be reachable without authentication. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; + +beforeAll(async () => { + // setupTestDb must run before any app import so DATA_DIR is set first + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('GET /api/health', () => { + it('returns 200 with status ok', async () => { + const res = await request(app).get('/api/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('returns uptime as a number', async () => { + const res = await request(app).get('/api/health'); + expect(typeof res.body.uptime).toBe('number'); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('does not require an auth token', async () => { + // No cookie, no Authorization header — must still return 200 + const res = await request(app).get('/api/health'); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); +}); diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts new file mode 100644 index 00000000..3e491c92 --- /dev/null +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -0,0 +1,46 @@ +/** + * Test DB helper — creates a temporary SQLite database, seeds it with a known + * admin credential, and sets process.env so DatabaseService uses it. + * + * Call this at the top of every test file *before* importing the app, + * because DatabaseService initialises its path on first getInstance() call. + */ +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import bcrypt from 'bcrypt'; +import crypto from 'crypto'; + +export const TEST_USERNAME = 'testadmin'; +export const TEST_PASSWORD = 'testpassword123'; +export let TEST_JWT_SECRET = ''; + +export async function setupTestDb(): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-')); + process.env.DATA_DIR = tmpDir; + // Also point COMPOSE_DIR to a temp dir so FileSystemService doesn't fail on missing dir + const composeDir = path.join(tmpDir, 'compose'); + fs.mkdirSync(composeDir, { recursive: true }); + process.env.COMPOSE_DIR = composeDir; + + // Initialise the DB (singleton will use DATA_DIR we just set) + const { DatabaseService } = await import('../../services/DatabaseService'); + const db = DatabaseService.getInstance(); + + // Seed admin credentials + const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); // cost=1 for speed in tests + TEST_JWT_SECRET = crypto.randomBytes(32).toString('hex'); + db.updateGlobalSetting('auth_username', TEST_USERNAME); + db.updateGlobalSetting('auth_password_hash', passwordHash); + db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET); + + return tmpDir; +} + +export function cleanupTestDb(tmpDir: string): void { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} diff --git a/backend/src/__tests__/nodes.test.ts b/backend/src/__tests__/nodes.test.ts new file mode 100644 index 00000000..9d1cc41f --- /dev/null +++ b/backend/src/__tests__/nodes.test.ts @@ -0,0 +1,96 @@ +/** + * Tests for node management API — focusing on api_url validation (SSRF fix C2). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => { + it('rejects localhost api_url', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/loopback/i); + }); + + it('rejects 127.0.0.1 api_url', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'bad-node-2', type: 'remote', api_url: 'http://127.0.0.1:5432' }); + expect(res.status).toBe(400); + }); + + it('rejects non-http scheme', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'bad-node-3', type: 'remote', api_url: 'ftp://example.com' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/http/i); + }); + + it('rejects malformed URL', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'bad-node-4', type: 'remote', api_url: 'not-a-url' }); + expect(res.status).toBe(400); + }); + + it('accepts valid LAN IP', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ + name: 'lan-node', + type: 'remote', + api_url: 'http://192.168.1.50:3000', + api_token: 'sometoken', + }); + // Should succeed (201 or 200) — not a validation error + expect(res.status).not.toBe(400); + }); + + it('requires api_url for remote nodes', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'missing-url', type: 'remote' }); + expect(res.status).toBe(400); + }); +}); + +describe('Stack name validation on GET routes (H3 fix)', () => { + it('rejects path traversal in GET /api/stacks/:stackName', async () => { + const res = await request(app) + .get('/api/stacks/..%2F..%2Fetc%2Fpasswd') + .set('Authorization', authHeader); + expect(res.status).toBe(400); + }); + + it('rejects dots in stack name', async () => { + const res = await request(app) + .get('/api/stacks/.hidden') + .set('Authorization', authHeader); + expect(res.status).toBe(400); + }); +}); diff --git a/backend/src/__tests__/validation.test.ts b/backend/src/__tests__/validation.test.ts new file mode 100644 index 00000000..cb905408 --- /dev/null +++ b/backend/src/__tests__/validation.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation'; + +// ─── isValidStackName ──────────────────────────────────────────────────────── + +describe('isValidStackName', () => { + it('accepts alphanumeric names', () => { + expect(isValidStackName('mystack')).toBe(true); + expect(isValidStackName('MyStack123')).toBe(true); + }); + + it('accepts hyphens and underscores', () => { + expect(isValidStackName('my-stack')).toBe(true); + expect(isValidStackName('my_stack')).toBe(true); + }); + + it('rejects path separators', () => { + expect(isValidStackName('../etc')).toBe(false); + expect(isValidStackName('foo/bar')).toBe(false); + expect(isValidStackName('foo\\bar')).toBe(false); + }); + + it('rejects dots', () => { + expect(isValidStackName('.hidden')).toBe(false); + expect(isValidStackName('foo.bar')).toBe(false); + }); + + it('rejects spaces and special characters', () => { + expect(isValidStackName('my stack')).toBe(false); + expect(isValidStackName('foo;rm -rf /')).toBe(false); + expect(isValidStackName('')).toBe(false); + }); +}); + +// ─── isValidRemoteUrl ──────────────────────────────────────────────────────── + +describe('isValidRemoteUrl', () => { + it('accepts valid http URLs', () => { + const result = isValidRemoteUrl('http://192.168.1.10:3000'); + expect(result.valid).toBe(true); + }); + + it('accepts valid https URLs', () => { + const result = isValidRemoteUrl('https://sencho.example.com'); + expect(result.valid).toBe(true); + }); + + it('rejects malformed URLs', () => { + const result = isValidRemoteUrl('not-a-url'); + expect(result.valid).toBe(false); + }); + + it('rejects non-http schemes', () => { + expect(isValidRemoteUrl('ftp://example.com').valid).toBe(false); + expect(isValidRemoteUrl('file:///etc/passwd').valid).toBe(false); + expect(isValidRemoteUrl('javascript:alert(1)').valid).toBe(false); + }); + + it('rejects localhost', () => { + expect(isValidRemoteUrl('http://localhost:3000').valid).toBe(false); + expect(isValidRemoteUrl('http://LOCALHOST:3000').valid).toBe(false); + }); + + it('rejects loopback IPs', () => { + expect(isValidRemoteUrl('http://127.0.0.1:3000').valid).toBe(false); + expect(isValidRemoteUrl('http://127.1.2.3').valid).toBe(false); + // Node.js URL.hostname preserves brackets: new URL('http://[::1]').hostname === '[::1]' + expect(isValidRemoteUrl('http://[::1]:3000').valid).toBe(false); + }); + + it('rejects 0.0.0.0', () => { + expect(isValidRemoteUrl('http://0.0.0.0:3000').valid).toBe(false); + }); + + it('allows LAN/private IPs (users need these for local network nodes)', () => { + // Users legitimately run Sencho nodes on their LAN + expect(isValidRemoteUrl('http://192.168.1.100:3000').valid).toBe(true); + expect(isValidRemoteUrl('http://10.0.0.5:3000').valid).toBe(true); + }); +}); + +// ─── isPathWithinBase ──────────────────────────────────────────────────────── + +describe('isPathWithinBase', () => { + it('accepts paths within the base directory', () => { + expect(isPathWithinBase('/app/compose/mystack/.env', '/app/compose/mystack')).toBe(true); + }); + + it('accepts the base directory itself', () => { + expect(isPathWithinBase('/app/compose/mystack', '/app/compose/mystack')).toBe(true); + }); + + it('rejects paths that escape via ..', () => { + expect(isPathWithinBase('/app/compose/mystack/../../../etc/passwd', '/app/compose/mystack')).toBe(false); + }); + + it('rejects sibling directories', () => { + expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index c79ad906..41dbb6dd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,9 @@ import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; -import WebSocket from 'ws'; +import rateLimit from 'express-rate-limit'; +import helmet from 'helmet'; +import WebSocket, { WebSocketServer } from 'ws'; import jwt from 'jsonwebtoken'; import DockerController, { globalDockerNetwork } from './services/DockerController'; import { FileSystemService } from './services/FileSystemService'; @@ -14,8 +16,6 @@ import si from 'systeminformation'; import http from 'http'; import httpProxy from 'http-proxy'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { spawn, exec } from 'child_process'; -import { promisify } from 'util'; import path from 'path'; import { HostTerminalService } from './services/HostTerminalService'; import { DatabaseService } from './services/DatabaseService'; @@ -25,15 +25,14 @@ import { ImageUpdateService } from './services/ImageUpdateService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; +import { isValidStackName, isValidRemoteUrl } from './utils/validation'; import YAML from 'yaml'; import fs, { promises as fsPromises } from 'fs'; -const execAsync = promisify(exec); - // Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls // util._extend internally. The warning fires at runtime when createProxyServer() is -// first invoked (NOT at import time), so intercepting process.emitWarning here — -// before the proxy instances are created below — fully prevents it. +// first invoked (NOT at import time), so intercepting process.emitWarning here - +// before the proxy instances are created below - fully prevents it. // http-proxy has no compatible update; this suppression is intentional and safe. const _origEmitWarning = process.emitWarning.bind(process); (process as any).emitWarning = (warning: any, ...args: any[]) => { @@ -64,11 +63,86 @@ const getCookieOptions = (req: Request) => ({ }); // Middleware + +// Security headers (X-Frame-Options, X-Content-Type-Options, etc.) +// crossOriginEmbedderPolicy: disabled — Monaco editor workers lack COEP headers. +// hsts: disabled — HSTS must only be set when the app is served over HTTPS. +// Enabling it over HTTP permanently breaks browser access for 1 year. +// contentSecurityPolicy.upgradeInsecureRequests: explicitly set to null. +// Helmet 8 merges custom directives with its defaults, which include this +// directive. It tells browsers to silently upgrade all HTTP sub-resource fetches +// to HTTPS. On a plain-HTTP self-hosted deployment (the common case) this causes +// every JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR, producing a blank page. +// Setting null is the Helmet 8 API to remove a default directive. +app.use(helmet({ + crossOriginEmbedderPolicy: false, + // COOP is only meaningful over HTTPS. Over HTTP the browser logs a warning + // and ignores it, creating noise in the console with no security benefit. + crossOriginOpenerPolicy: false, + hsts: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + baseUri: ["'self'"], + fontSrc: ["'self'", 'https:', 'data:'], + formAction: ["'self'"], + frameAncestors: ["'self'"], + imgSrc: ["'self'", 'data:'], + objectSrc: ["'none'"], + scriptSrc: ["'self'"], + scriptSrcAttr: ["'none'"], + styleSrc: ["'self'", 'https:', "'unsafe-inline'"], + // connect-src: explicit 'self' covers same-origin fetch/XHR/WebSocket. + // ws: and wss: are included for WebSocket connections in any scheme context. + connectSrc: ["'self'", 'ws:', 'wss:'], + // worker-src: Monaco editor creates Web Workers via blob: URLs for language + // services (syntax highlighting, intellisense). Without blob: they silently fail. + workerSrc: ["'self'", 'blob:'], + // Helmet 8 merges custom directives with its defaults, which include + // upgrade-insecure-requests. Setting it to null explicitly removes it. + // On plain-HTTP self-hosted deployments (the common case) this directive + // causes every JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR → blank page. + upgradeInsecureRequests: null, + }, + }, +})); + +// CORS — in production restrict to the configured frontend origin. +// In development, mirror the request origin so Vite's dev server works. +const corsOrigin = process.env.NODE_ENV === 'production' && process.env.FRONTEND_URL + ? process.env.FRONTEND_URL + : true; + app.use(cors({ - origin: true, + origin: corsOrigin, credentials: true, })); -app.use(express.json()); +// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body +// consumed here: express.json() drains the IncomingMessage stream into req.body +// and http-proxy then pipes an already-ended stream to the remote server. +// When Node.js pipes an ended readable it calls process.nextTick(dest.end()), +// which fires *before* the proxyReq socket event, so any attempt to write the +// body inside the proxyReq handler results in "write after end" and the request +// hangs. Solution: skip JSON parsing for remote-targeted /api/ requests so the +// raw stream flows through the proxy intact. +app.use((req: Request, res: Response, next: NextFunction): void => { + const nodeIdHeader = req.headers['x-node-id']; + if (nodeIdHeader) { + const nodeId = parseInt(nodeIdHeader as string, 10); + const node = NodeRegistry.getInstance().getNode(nodeId); + if ( + node?.type === 'remote' && + req.path.startsWith('/api/') && + !req.path.startsWith('/api/auth/') && + !req.path.startsWith('/api/nodes') + ) { + // Preserve body stream for proxy piping + next(); + return; + } + } + express.json()(req, res, next); +}); app.use(cookieParser()); // Node Context Middleware @@ -122,13 +196,15 @@ wsProxyServer.on('error', (err, _req, socket: any) => { }); // Authentication Middleware -// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy) +// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy). +// Bearer token is evaluated first: node-to-node proxy calls always carry a Bearer token and +// should never be shadowed by a stale or cross-instance cookie. const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise => { const cookieToken = req.cookies[COOKIE_NAME]; const bearerToken = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null; - const token = cookieToken || bearerToken; + const token = bearerToken || cookieToken; if (!token) { res.status(401).json({ error: 'Authentication required' }); @@ -150,6 +226,22 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): } }; +// Rate limiter for auth endpoints — prevents brute-force attacks. +// Production: 5 attempts per 15-minute window per IP. +// Development: 100 attempts (so E2E tests and local tooling are not blocked). +const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 5 : 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many attempts. Please try again in 15 minutes.' }, +}); + +// Public health endpoint - no auth required (used by Docker HEALTHCHECK and uptime monitors) +app.get('/api/health', (_req: Request, res: Response): void => { + res.json({ status: 'ok', uptime: process.uptime() }); +}); + // Auth Routes (no authentication required) // Check if setup is needed @@ -165,7 +257,7 @@ app.get('/api/auth/status', async (req: Request, res: Response): Promise = }); // Initial setup endpoint -app.post('/api/auth/setup', async (req: Request, res: Response): Promise => { +app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise => { try { const dbSvc = DatabaseService.getInstance(); const settings = dbSvc.getGlobalSettings(); @@ -216,7 +308,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise = }); // Login endpoint -app.post('/api/auth/login', async (req: Request, res: Response): Promise => { +app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise => { const { username, password } = req.body; if (!username || !password) { @@ -357,7 +449,7 @@ const remoteNodeProxy = createProxyMiddleware({ proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`); } // Strip the ?nodeId= query param so the remote's nodeContextMiddleware - // doesn't reject the request with 404 ("Node X not found") — the remote + // doesn't reject the request with 404 ("Node X not found") - the remote // has no record of the gateway's node IDs and should treat the request // as local. This affects endpoints like EventSource /api/containers/:id/logs // that pass nodeId as a query param rather than the x-node-id header. @@ -368,11 +460,28 @@ const remoteNodeProxy = createProxyMiddleware({ const newQs = params.toString(); proxyReq.path = pathname + (newQs ? `?${newQs}` : ''); } + // Body forwarding: the conditional json parser (see top of file) skips + // parsing for remote requests, so req's raw stream is intact and + // http-proxy's req.pipe(proxyReq) forwards the body automatically. + // No manual body rewriting needed here. + }, + proxyRes: (proxyRes) => { + // Mark every response forwarded from a remote node with a sentinel header. + // The frontend (apiFetch / fetchForNode) checks this before firing the + // global 'sencho-unauthorized' event: a 401 from a remote means the stored + // api_token for that node is invalid — not that the user's own session + // expired. Without this distinction, any node with a bad token causes an + // immediate logout loop. + proxyRes.headers['x-sencho-proxy'] = '1'; }, error: (err, _req, proxyRes) => { console.error('[Proxy] Remote node error:', (err as Error).message); - if (!(proxyRes as Response).headersSent) { - (proxyRes as Response).status(502).json({ + // proxyRes can be either a ServerResponse (HTTP) or a raw Socket (WS/TCP errors). + // Only attempt to send an HTTP 502 if it is a proper ServerResponse with a + // headersSent flag - otherwise silently drop (the socket will be destroyed). + const res = proxyRes as any; + if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') { + res.status(502).json({ error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.' }); } @@ -408,10 +517,22 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { const server = http.createServer(app); // WebSocket server with authentication -const wss = new WebSocket.Server({ noServer: true }); +const wss = new WebSocketServer({ noServer: true }); let terminalWs: WebSocket | null = null; +// Notification push - set of authenticated browser clients subscribed to real-time alerts +const notificationSubscribers = new Set(); +NotificationService.getInstance().setBroadcaster((notification) => { + if (notificationSubscribers.size === 0) return; + const msg = JSON.stringify({ type: 'notification', payload: notification }); + for (const ws of notificationSubscribers) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } +}); + // Handle WebSocket upgrade with JWT authentication server.on('upgrade', async (req, socket, head) => { // Parse cookies from the upgrade request @@ -424,7 +545,9 @@ server.on('upgrade', async (req, socket, head) => { const cookieToken = cookies[COOKIE_NAME]; const authHeader = req.headers['authorization'] as string | undefined; const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; - const token = cookieToken || bearerToken; + // Prefer Bearer over cookie: node-to-node proxy upgrades carry a Bearer token and must + // not be shadowed by a browser cookie signed with a different instance's JWT secret. + const token = bearerToken || cookieToken; if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); @@ -436,7 +559,11 @@ server.on('upgrade', async (req, socket, head) => { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); - jwt.verify(token, jwtSecret); + const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string }; + + // Node proxy tokens are machine-to-machine credentials and must never be granted + // interactive terminal access (host console or container exec). + const isProxyToken = decoded.scope === 'node_proxy'; const url = req.url || ''; const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); @@ -447,12 +574,56 @@ server.on('upgrade', async (req, socket, head) => { const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); const node = NodeRegistry.getInstance().getNode(nodeId); + // Notification push channel - local only when no remote nodeId is specified. + // When a nodeId pointing to a remote node is provided, fall through to the + // proxy block below so the browser subscribes to that remote node's push stream. + if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) { + const notifWss = new WebSocketServer({ noServer: true }); + notifWss.handleUpgrade(req, socket, head, (ws) => { + notifWss.close(); + notificationSubscribers.add(ws); + ws.on('close', () => notificationSubscribers.delete(ws)); + ws.on('error', () => { notificationSubscribers.delete(ws); ws.terminate(); }); + }); + return; + } + // Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance if (node && node.type === 'remote' && node.api_url && node.api_token) { const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); - req.headers['authorization'] = `Bearer ${node.api_token}`; + + // Interactive console paths (host console / container exec) are guarded on the remote by + // an isProxyToken check that rejects the long-lived api_token (scope: 'node_proxy'). + // Exchange it for a short-lived console_session token before forwarding so the remote + // allows the connection while keeping the guard intact for direct api_token access. + const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws'; + let bearerTokenForProxy = node.api_token; + if (isInteractiveConsolePath) { + try { + const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${node.api_token}` }, + }); + if (tokenRes.ok) { + const data = await tokenRes.json() as { token?: string }; + if (typeof data.token === 'string') bearerTokenForProxy = data.token; + } else { + console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`); + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + return; + } + } catch (e) { + console.error('[WS Proxy] Failed to fetch remote console token:', (e as Error).message); + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + return; + } + } + + req.headers['authorization'] = `Bearer ${bearerTokenForProxy}`; delete req.headers['x-node-id']; - // Strip the browser's session cookie — it is signed by this instance's JWT secret and + // Strip the browser's session cookie - it is signed by this instance's JWT secret and // would fail verification on the remote. Auth is handled exclusively via the Bearer token. delete req.headers['cookie']; // Strip nodeId from the forwarded URL so the remote treats the request as a local one. @@ -471,8 +642,12 @@ server.on('upgrade', async (req, socket, head) => { if (logsMatch) { // Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs - const logsWss = new WebSocket.Server({ noServer: true }); + const logsWss = new WebSocketServer({ noServer: true }); logsWss.handleUpgrade(req, socket, head, (ws) => { + // Close the per-connection server immediately after the upgrade is complete. + // The wss instance is only needed to negotiate the handshake; keeping it open + // would accumulate listeners and allocate memory for every connection. + logsWss.close(); const stackName = decodeURIComponent(logsMatch[1]); try { ComposeService.getInstance(nodeId).streamLogs(stackName, ws); @@ -484,14 +659,29 @@ server.on('upgrade', async (req, socket, head) => { } }); } else if (hostConsoleMatch) { - const hostConsoleWss = new WebSocket.Server({ noServer: true }); + // Node proxy tokens must not access interactive host terminals + if (isProxyToken) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + const hostConsoleWss = new WebSocketServer({ noServer: true }); hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { + hostConsoleWss.close(); let targetDirectory = ''; try { - targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir(); + const baseDir = FileSystemService.getInstance(nodeId).getBaseDir(); const stackParam = parsedUrl.searchParams.get('stack'); if (stackParam) { - targetDirectory = path.join(targetDirectory, stackParam); + const resolved = path.resolve(baseDir, stackParam); + if (!resolved.startsWith(path.resolve(baseDir))) { + ws.send('Error: Invalid stack path\r\n'); + ws.close(); + return; + } + targetDirectory = resolved; + } else { + targetDirectory = baseDir; } } catch (e) { targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir(); @@ -507,7 +697,13 @@ server.on('upgrade', async (req, socket, head) => { } }); } else { - // Generic terminal WebSocket + // Generic terminal WebSocket (container exec) + // Node proxy tokens must not access interactive container terminals + if (isProxyToken) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); @@ -539,14 +735,20 @@ wss.on('connection', (ws) => { // message belongs to the gateway's DB and won't resolve locally. Fall back to local. let nodeId = requestedId; try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } - DockerController.getInstance(nodeId).streamStats(data.containerId, ws); + DockerController.getInstance(nodeId).streamStats(data.containerId, ws).catch((err: Error) => { + console.error('[WS] streamStats error:', err.message); + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); } else if (data.action === 'execContainer') { // Handle container exec for bash access // Input, resize, and cleanup are handled inside execContainer's closure const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); let nodeId = requestedId; try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } - DockerController.getInstance(nodeId).execContainer(data.containerId, ws); + DockerController.getInstance(nodeId).execContainer(data.containerId, ws).catch((err: Error) => { + console.error('[WS] execContainer error:', err.message); + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); } } catch (error) { // Malformed JSON - ignore silently @@ -580,6 +782,9 @@ app.get('/api/stacks', async (req: Request, res: Response) => { app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName); res.send(content); } catch (error) { @@ -640,20 +845,22 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis const service = parsed.services[serviceName]; if (!service?.env_file) continue; + const addEnvPath = (rawPath: string) => { + const resolved = path.resolve(stackDir, rawPath); + // Reject paths that escape the stack directory + if (!resolved.startsWith(path.resolve(stackDir) + path.sep) && resolved !== path.resolve(stackDir)) { + console.warn(`[Security] env_file path "${rawPath}" escapes stack directory — skipping`); + return; + } + envFiles.add(resolved); + }; + if (typeof service.env_file === 'string') { - const resolvedPath = path.isAbsolute(service.env_file) - ? service.env_file - : path.resolve(stackDir, service.env_file); - envFiles.add(resolvedPath); + addEnvPath(service.env_file); } else if (Array.isArray(service.env_file)) { for (const entry of service.env_file) { const entryPath = typeof entry === 'string' ? entry : (entry?.path || ''); - if (entryPath) { - const resolvedPath = path.isAbsolute(entryPath) - ? entryPath - : path.resolve(stackDir, entryPath); - envFiles.add(resolvedPath); - } + if (entryPath) addEnvPath(entryPath); } } } @@ -673,6 +880,9 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); res.json({ envFiles: envPaths }); } catch (error) { @@ -683,6 +893,9 @@ app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => { app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const requestedFile = req.query.file as string | undefined; const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); @@ -946,15 +1159,27 @@ app.post('/api/convert', async (req: Request, res: Response) => { // Get all containers stats for dashboard app.get('/api/stats', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const containers = await dockerController.getRunningContainers(); - const allContainers = await dockerController.getAllContainers(); + const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(req.nodeId)); + const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers(); - const active = containers.length; - const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length; + // A container is "managed" if Docker started it from within COMPOSE_DIR. + // We use com.docker.compose.project.working_dir rather than project name because + // stacks launched from the COMPOSE_DIR root (not a subdirectory) all share the + // project name of the root folder — causing false "external" classification. + const isManagedByComposeDir = (c: any): boolean => { + const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; + if (!workingDir) return false; + const resolved = path.resolve(workingDir); + return resolved === composeDir || resolved.startsWith(composeDir + path.sep); + }; + + const active = allContainers.filter((c: any) => c.State === 'running').length; + const exited = allContainers.filter((c: any) => c.State === 'exited').length; const total = allContainers.length; + const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length; + const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length; - res.json({ active, exited, total, inactive: total - active - exited }); + res.json({ active, managed, unmanaged, exited, total }); } catch (error) { res.status(500).json({ error: 'Failed to fetch stats' }); } @@ -977,7 +1202,7 @@ app.get('/api/logs/global', async (req: Request, res: Response) => { await Promise.all(containers.map(async (c) => { const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; - let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); // Standardize naming: Strip stack name prefix if it exists let containerName = rawName; @@ -1053,9 +1278,11 @@ app.get('/api/logs/global', async (req: Request, res: Response) => { } })); - // Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines + // Sort globally by timestamp ascending (newest bottom). + // Limit to 500 lines - the client renders at most 300 rows at once, so + // sending 2000 lines was wasting bandwidth and inflating JSON parse time. allLogs.sort((a, b) => a.timestampMs - b.timestampMs); - res.json(allLogs.slice(-2000)); + res.json(allLogs.slice(-500)); } catch (error) { res.status(500).json({ error: 'Failed to fetch global logs' }); } @@ -1075,7 +1302,7 @@ app.get('/api/logs/global/stream', async (req: Request, res: Response) => { await Promise.all(containers.map(async (c) => { const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; - let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); let containerName = rawName; if (rawName.startsWith(`${stackName}-`)) containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, ''); else if (rawName.startsWith(`${stackName}_`)) containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, ''); @@ -1225,11 +1452,48 @@ app.post('/api/agents', async (req: Request, res: Response) => { } }); +// Keys that contain auth credentials - never exposed to the frontend or writable via settings API +const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']); + +// Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials) +const ALLOWED_SETTING_KEYS = new Set([ + 'host_cpu_limit', + 'host_ram_limit', + 'host_disk_limit', + 'docker_janitor_gb', + 'global_crash', + 'global_logs_refresh', + 'developer_mode', + 'template_registry_url', + 'metrics_retention_hours', + 'log_retention_days', +]); + +// Zod schema for bulk PATCH - all keys optional, present keys fully validated +import { z } from 'zod'; +const SettingsPatchSchema = z.object({ + host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), + docker_janitor_gb: z.coerce.number().min(0).transform(String), + global_crash: z.enum(['0', '1']), + global_logs_refresh: z.enum(['1', '3', '5', '10']), + developer_mode: z.enum(['0', '1']), + template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), + metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String), + log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), +}).partial(); + app.get('/api/settings', async (req: Request, res: Response) => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); + // Strip auth credentials - these are managed exclusively by /api/auth/* endpoints + for (const key of PRIVATE_SETTINGS_KEYS) { + delete settings[key]; + } res.json(settings); } catch (error) { + console.error('Failed to fetch settings:', error); res.status(500).json({ error: 'Failed to fetch settings' }); } }); @@ -1237,13 +1501,43 @@ app.get('/api/settings', async (req: Request, res: Response) => { app.post('/api/settings', async (req: Request, res: Response) => { try { const { key, value } = req.body; - DatabaseService.getInstance().updateGlobalSetting(key, value); + if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) { + res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` }); + return; + } + if (value === undefined || value === null) { + res.status(400).json({ error: 'Setting value is required' }); + return; + } + DatabaseService.getInstance().updateGlobalSetting(key, String(value)); res.json({ success: true }); } catch (error) { + console.error('Failed to update setting:', error); res.status(500).json({ error: 'Failed to update setting' }); } }); +app.patch('/api/settings', async (req: Request, res: Response) => { + try { + const parsed = SettingsPatchSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }); + return; + } + const db = DatabaseService.getInstance(); + const updateMany = db.getDb().transaction((entries: [string, string][]) => { + for (const [k, v] of entries) { + db.updateGlobalSetting(k, v); + } + }); + updateMany(Object.entries(parsed.data) as [string, string][]); + res.json({ success: true }); + } catch (error) { + console.error('Failed to bulk update settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } +}); + app.get('/api/alerts', async (req: Request, res: Response) => { try { let stackName = req.query.stackName as string | undefined; @@ -1256,12 +1550,26 @@ app.get('/api/alerts', async (req: Request, res: Response) => { } }); +const AlertCreateSchema = z.object({ + stack_name: z.string().min(1).max(255), + metric: z.enum(['cpu_percent', 'memory_percent', 'memory_mb', 'net_rx', 'net_tx', 'restart_count']), + operator: z.enum(['>', '>=', '<', '<=', '==']), + threshold: z.number().min(0), + duration_mins: z.coerce.number().int().min(0).max(1440), + cooldown_mins: z.coerce.number().int().min(0).max(10080), +}); + app.post('/api/alerts', async (req: Request, res: Response) => { + const parsed = AlertCreateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors }); + return; + } try { - const alert = req.body; - DatabaseService.getInstance().addStackAlert(alert); + DatabaseService.getInstance().addStackAlert(parsed.data); res.json({ success: true }); } catch (error) { + console.error('Failed to add alert:', error); res.status(500).json({ error: 'Failed to add alert' }); } }); @@ -1276,7 +1584,7 @@ app.delete('/api/alerts/:id', async (req: Request, res: Response) => { } }); -app.get('/api/notifications', async (req: Request, res: Response) => { +app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => { try { const history = DatabaseService.getInstance().getNotificationHistory(); res.json(history); @@ -1285,7 +1593,7 @@ app.get('/api/notifications', async (req: Request, res: Response) => { } }); -app.post('/api/notifications/read', async (req: Request, res: Response) => { +app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => { try { DatabaseService.getInstance().markAllNotificationsRead(); res.json({ success: true }); @@ -1294,7 +1602,7 @@ app.post('/api/notifications/read', async (req: Request, res: Response) => { } }); -app.delete('/api/notifications/:id', async (req: Request, res: Response) => { +app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string, 10); DatabaseService.getInstance().deleteNotification(id); @@ -1304,7 +1612,7 @@ app.delete('/api/notifications/:id', async (req: Request, res: Response) => { } }); -app.delete('/api/notifications', async (req: Request, res: Response) => { +app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => { try { DatabaseService.getInstance().deleteAllNotifications(); res.json({ success: true }); @@ -1313,7 +1621,7 @@ app.delete('/api/notifications', async (req: Request, res: Response) => { } }); -app.post('/api/notifications/test', async (req: Request, res: Response) => { +app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => { try { const { type, url } = req.body; await NotificationService.getInstance().testDispatch(type, url); @@ -1323,6 +1631,27 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => { } }); +// Issue a short-lived console session token for WebSocket proxy delegation. +// When the gateway needs to proxy an interactive terminal (host console or container exec) +// to a remote node, it calls this endpoint (authenticated with the long-lived api_token) +// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session' +// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths. +app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => { + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'No JWT secret configured' }); + return; + } + const consoleToken = jwt.sign({ scope: 'console_session' }, jwtSecret, { expiresIn: '60s' }); + res.json({ token: consoleToken }); + } catch (error) { + console.error('Failed to issue console token:', error); + res.status(500).json({ error: 'Failed to issue console token' }); + } +}); + // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => { @@ -1354,13 +1683,24 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { app.post('/api/system/prune/system', async (req: Request, res: Response) => { try { - const { target } = req.body; // 'containers', 'images', 'networks', 'volumes' + const { target, scope } = req.body as { target: string; scope?: string }; if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { return res.status(400).json({ error: 'Invalid prune target' }); } const dockerController = DockerController.getInstance(req.nodeId); - const result = await dockerController.pruneSystem(target); + const pruneScope = scope === 'managed' ? 'managed' : 'all'; + + let result: { success: boolean; reclaimedBytes: number }; + if (pruneScope === 'managed' && target !== 'containers') { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + result = await dockerController.pruneManagedOnly( + target as 'images' | 'volumes' | 'networks', + knownStacks + ); + } else { + result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes'); + } res.json({ message: 'Prune completed', ...result }); } catch (error: any) { @@ -1371,8 +1711,8 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => { app.get('/api/system/docker-df', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const df = await dockerController.getDiskUsage(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks); res.json(df); } catch (error) { console.error('Failed to fetch docker disk usage:', error); @@ -1380,10 +1720,23 @@ app.get('/api/system/docker-df', async (req: Request, res: Response) => { } }); +// Single endpoint returning classified images, volumes, and networks in one call +app.get('/api/system/resources', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); + res.json(result); + } catch (error) { + console.error('Failed to fetch classified resources:', error); + res.status(500).json({ error: 'Failed to fetch resources' }); + } +}); + +// Keep legacy endpoints for backward compat with remote proxy routing app.get('/api/system/images', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const images = await dockerController.getImages(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); res.json(images); } catch (error) { console.error('Failed to fetch images:', error); @@ -1393,8 +1746,8 @@ app.get('/api/system/images', async (req: Request, res: Response) => { app.get('/api/system/volumes', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const volumes = await dockerController.getVolumes(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); res.json(volumes); } catch (error) { console.error('Failed to fetch volumes:', error); @@ -1404,8 +1757,8 @@ app.get('/api/system/volumes', async (req: Request, res: Response) => { app.get('/api/system/networks', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const networks = await dockerController.getNetworks(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); res.json(networks); } catch (error) { console.error('Failed to fetch networks:', error); @@ -1463,6 +1816,11 @@ app.get('/api/templates', async (req: Request, res: Response) => { } }); +app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => { + templateService.clearCache(); + res.json({ success: true }); +}); + app.post('/api/templates/deploy', async (req: Request, res: Response) => { try { const { stackName, template, envVars } = req.body; @@ -1564,6 +1922,7 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp // Node Management API // ========================= + // List all nodes app.get('/api/nodes', async (req: Request, res: Response) => { try { @@ -1599,8 +1958,14 @@ app.post('/api/nodes', async (req: Request, res: Response) => { if (!type || !['local', 'remote'].includes(type)) { return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); } - if (type === 'remote' && (!api_url || typeof api_url !== 'string')) { - return res.status(400).json({ error: 'API URL is required for remote nodes' }); + if (type === 'remote') { + if (!api_url || typeof api_url !== 'string') { + return res.status(400).json({ error: 'API URL is required for remote nodes' }); + } + const urlCheck = isValidRemoteUrl(api_url); + if (!urlCheck.valid) { + return res.status(400).json({ error: urlCheck.reason }); + } } const id = DatabaseService.getInstance().addNode({ @@ -1628,6 +1993,13 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => { const id = parseInt(req.params.id as string); const updates = req.body; + if (updates.api_url !== undefined && updates.api_url !== '') { + const urlCheck = isValidRemoteUrl(updates.api_url); + if (!urlCheck.valid) { + return res.status(400).json({ error: urlCheck.reason }); + } + } + DatabaseService.getInstance().updateNode(id, updates); // Evict cached Docker connection so it reconnects with new config @@ -1711,4 +2083,35 @@ async function startServer() { }); } -startServer(); +// Only start the server when this file is the entry point (not when imported by tests). +if (require.main === module) { + startServer(); +} + +// Exports used by tests (supertest requires the http.Server instance). +export { app, server }; + +// Graceful shutdown — allows in-flight requests to finish, then cleanly stops +// background services and closes the SQLite connection before the process exits. +// Docker sends SIGTERM when the container stops; Ctrl-C sends SIGINT in dev. +const gracefulShutdown = (signal: string) => { + console.log(`[Shutdown] ${signal} received — shutting down gracefully…`); + + server.close(() => { + console.log('[Shutdown] HTTP server closed'); + try { MonitorService.getInstance().stop(); } catch { /* already stopped */ } + try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ } + try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ } + console.log('[Shutdown] Done — exiting'); + process.exit(0); + }); + + // Force-exit after 10 s if connections refuse to drain + setTimeout(() => { + console.error('[Shutdown] Timed out waiting for connections — forcing exit'); + process.exit(1); + }, 10_000).unref(); +}; + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 7cbb2855..76f07ea8 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -110,7 +110,7 @@ export class ComposeService { if (exitCode !== 0) { const logs = await container.logs({ stdout: true, stderr: true, tail: 50 }); - let logStr = logs.toString('utf-8'); + const logStr = logs.toString('utf-8'); throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`); } } @@ -157,7 +157,7 @@ export class ComposeService { let activeProcesses = 0; let streamEndedHandled = false; - let localProcesses: ReturnType[] = []; + const localProcesses: ReturnType[] = []; const onWsClose = () => { localProcesses.forEach(cp => { try { cp.kill(); } catch { } }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 7125931f..f88c9959 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -136,6 +136,11 @@ export class DatabaseService { status TEXT NOT NULL DEFAULT 'unknown', created_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS system_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); `); // Apply migrations safely (ignore if columns already exist) @@ -167,6 +172,8 @@ export class DatabaseService { stmt.run('docker_janitor_gb', '5'); stmt.run('global_logs_refresh', '5'); stmt.run('developer_mode', '0'); + stmt.run('metrics_retention_hours', '24'); + stmt.run('log_retention_days', '30'); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; @@ -244,6 +251,17 @@ export class DatabaseService { stmt.run(key, value); } + // --- System State (operational/runtime values - not user-defined config) --- + + public getSystemState(key: string): string | null { + const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined; + return row?.value ?? null; + } + + public setSystemState(key: string, value: string): void { + this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value); + } + // --- Stack Alerts --- public getStackAlerts(stackName?: string): StackAlert[] { @@ -292,9 +310,9 @@ export class DatabaseService { })); } - public addNotificationHistory(notification: Omit): void { + public addNotificationHistory(notification: Omit): NotificationHistory { const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)'); - stmt.run(notification.level, notification.message, notification.timestamp); + const result = stmt.run(notification.level, notification.message, notification.timestamp); this.db.exec(` DELETE FROM notification_history @@ -302,6 +320,14 @@ export class DatabaseService { SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100 ) `); + + return { + id: result.lastInsertRowid as number, + level: notification.level, + message: notification.message, + timestamp: notification.timestamp, + is_read: false, + }; } public markAllNotificationsRead(): void { @@ -353,6 +379,11 @@ export class DatabaseService { stmt.run(cutoff); } + public cleanupOldNotifications(daysToKeep = 30): void { + const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000); + this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff); + } + // --- Nodes --- public getNodes(): Node[] { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 6d9467e6..71a0ba94 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -11,6 +11,32 @@ import { NodeRegistry } from './NodeRegistry'; const execAsync = promisify(exec); const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose'; +export interface ClassifiedImage { + Id: string; + RepoTags: string[]; + Size: number; + Containers: number; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'unused'; +} + +export interface ClassifiedVolume { + Name: string; + Driver: string; + Mountpoint: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged'; +} + +export interface ClassifiedNetwork { + Id: string; + Name: string; + Driver: string; + Scope: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'system'; +} + class DockerController { private docker: Docker; private nodeId: number; @@ -65,7 +91,7 @@ class DockerController { const calculateReclaimableVolumes = (items: any[]) => { if (!items || !Array.isArray(items)) return 0; return items.filter(i => i.UsageData?.RefCount === 0).reduce((acc, item) => { - let size = item.UsageData?.Size || 0; + const size = item.UsageData?.Size || 0; return acc + size; }, 0); }; @@ -112,6 +138,172 @@ class DockerController { return this.validateApiData(data); } + public async getClassifiedResources(knownStackNames: string[]): Promise<{ + images: ClassifiedImage[]; + volumes: ClassifiedVolume[]; + networks: ClassifiedNetwork[]; + }> { + const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']); + const knownSet = new Set(knownStackNames); + + const [rawImages, rawVolumeData, rawNetworks, allContainers] = await Promise.all([ + this.docker.listImages({ all: false }), + this.docker.listVolumes(), + this.docker.listNetworks(), + this.docker.listContainers({ all: true }), + ]); + + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + + // Build imageId → project mapping from container labels + const imageToProject = new Map(); + for (const c of allContainers as any[]) { + const project: string | undefined = c.Labels?.['com.docker.compose.project']; + if (project && c.ImageID) imageToProject.set(c.ImageID, project); + } + + const images: ClassifiedImage[] = this.validateApiData(rawImages).map((img: any) => { + const project = imageToProject.get(img.Id) ?? null; + const managedStatus: ClassifiedImage['managedStatus'] = + img.Containers === 0 ? 'unused' : + project && knownSet.has(project) ? 'managed' : 'unmanaged'; + return { + Id: img.Id, + RepoTags: img.RepoTags ?? [], + Size: img.Size ?? 0, + Containers: img.Containers ?? 0, + managedBy: managedStatus === 'managed' ? project : null, + managedStatus, + }; + }); + + const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => { + const project: string | undefined = vol.Labels?.['com.docker.compose.project']; + const managedStatus: ClassifiedVolume['managedStatus'] = + project && knownSet.has(project) ? 'managed' : 'unmanaged'; + return { + Name: vol.Name, + Driver: vol.Driver, + Mountpoint: vol.Mountpoint, + managedBy: managedStatus === 'managed' ? project! : null, + managedStatus, + }; + }); + + const networks: ClassifiedNetwork[] = this.validateApiData(rawNetworks).map((net: any) => { + if (SYSTEM_NETWORKS.has(net.Name)) { + return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const }; + } + const project: string | undefined = net.Labels?.['com.docker.compose.project']; + const managedStatus: ClassifiedNetwork['managedStatus'] = + project && knownSet.has(project) ? 'managed' : 'unmanaged'; + return { + Id: net.Id, + Name: net.Name, + Driver: net.Driver, + Scope: net.Scope, + managedBy: managedStatus === 'managed' ? project! : null, + managedStatus, + }; + }); + + return { images, volumes, networks }; + } + + public async pruneManagedOnly( + target: 'images' | 'volumes' | 'networks', + knownStackNames: string[] + ): Promise<{ success: boolean; reclaimedBytes: number }> { + const knownSet = new Set(knownStackNames); + let reclaimedBytes = 0; + + if (target === 'volumes') { + const rawVolumeData = await this.docker.listVolumes(); + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + const prunable = rawVolumes.filter((v: any) => { + const project: string | undefined = v.Labels?.['com.docker.compose.project']; + return project && knownSet.has(project) && (v.UsageData?.RefCount ?? 1) === 0; + }); + for (const vol of prunable) { + try { + await this.docker.getVolume(vol.Name).remove({ force: true }); + reclaimedBytes += vol.UsageData?.Size ?? 0; + } catch (e) { + console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e); + } + } + } else if (target === 'networks') { + const rawNetworks = await this.docker.listNetworks(); + const prunable = (rawNetworks as any[]).filter((n: any) => { + const project: string | undefined = n.Labels?.['com.docker.compose.project']; + return project && knownSet.has(project); + }); + for (const net of prunable) { + try { + await this.docker.getNetwork(net.Id).remove({ force: true }); + } catch (e) { + console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e); + } + } + } else if (target === 'images') { + const allContainers = await this.docker.listContainers({ all: true }); + const unmanagedImageIds = new Set(); + for (const c of allContainers as any[]) { + const project: string | undefined = c.Labels?.['com.docker.compose.project']; + if (!project || !knownSet.has(project)) unmanagedImageIds.add(c.ImageID); + } + const rawImages = await this.docker.listImages({ all: false }); + const prunable = (rawImages as any[]).filter((img: any) => + img.Containers === 0 && !unmanagedImageIds.has(img.Id) + ); + for (const img of prunable) { + try { + await this.docker.getImage(img.Id).remove({ force: true }); + reclaimedBytes += img.Size ?? 0; + } catch (e) { + console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e); + } + } + } + + return { success: true, reclaimedBytes }; + } + + public async getDiskUsageClassified(knownStackNames: string[]): Promise<{ + reclaimableImages: number; + reclaimableContainers: number; + reclaimableVolumes: number; + managedImageBytes: number; + unmanagedImageBytes: number; + managedVolumeBytes: number; + unmanagedVolumeBytes: number; + }> { + const [base, classified] = await Promise.all([ + this.getDiskUsage(), + this.getClassifiedResources(knownStackNames), + ]); + + const managedImageBytes = classified.images + .filter(i => i.managedStatus === 'managed') + .reduce((acc, i) => acc + i.Size, 0); + const unmanagedImageBytes = classified.images + .filter(i => i.managedStatus === 'unmanaged') + .reduce((acc, i) => acc + i.Size, 0); + + const rawVolumeData = await this.docker.listVolumes(); + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + const knownSet = new Set(knownStackNames); + + const managedVolumeBytes = rawVolumes + .filter((v: any) => knownSet.has(v.Labels?.['com.docker.compose.project'] ?? '')) + .reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0); + const unmanagedVolumeBytes = rawVolumes + .filter((v: any) => !knownSet.has(v.Labels?.['com.docker.compose.project'] ?? '')) + .reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0); + + return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes }; + } + public async removeImage(id: string) { const image = this.docker.getImage(id); await image.remove({ force: true }); @@ -425,15 +617,27 @@ class DockerController { const stats = await container.stats({ stream: true }); stats.on('data', (chunk: Buffer) => { - ws.send(chunk.toString()); + if (ws.readyState === WebSocket.OPEN) { + ws.send(chunk.toString()); + } }); stats.on('error', (err: Error) => { - ws.send(JSON.stringify({ error: err.message })); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ error: err.message })); + } }); stats.on('end', () => { - ws.send(JSON.stringify({ end: true })); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ end: true })); + } + }); + + // Destroy the Docker stats stream when the WebSocket closes to prevent + // orphaned streams polling the daemon after client disconnect. + ws.on('close', () => { + try { (stats as any).destroy(); } catch { /* stream already ended */ } }); } @@ -539,7 +743,7 @@ class DockerController { } } -export let globalDockerNetwork = { rxSec: 0, txSec: 0 }; +export const globalDockerNetwork = { rxSec: 0, txSec: 0 }; let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() }; export const updateGlobalDockerNetwork = async () => { diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 91e06bca..54889602 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1,5 +1,4 @@ import path from 'path'; -import fs from 'fs'; import { promises as fsPromises } from 'fs'; import { NodeRegistry } from './NodeRegistry'; diff --git a/backend/src/services/HostTerminalService.ts b/backend/src/services/HostTerminalService.ts index ce38cbfe..98436682 100644 --- a/backend/src/services/HostTerminalService.ts +++ b/backend/src/services/HostTerminalService.ts @@ -16,12 +16,19 @@ export class HostTerminalService { static spawnTerminal(ws: WebSocket, targetDirectory: string) { const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell(); + // Strip sensitive backend secrets from the PTY environment so they are not + // visible to the console user via `env` / `printenv`. + const SENSITIVE_KEYS = ['JWT_SECRET', 'AUTH_PASSWORD', 'AUTH_PASSWORD_HASH', 'DATABASE_URL']; + const safeEnv = Object.fromEntries( + Object.entries(process.env as Record).filter(([k]) => !SENSITIVE_KEYS.includes(k)) + ); + const ptyProcess = pty.spawn(shell, [], { name: 'xterm-color', cols: 80, rows: 30, cwd: targetDirectory, - env: process.env as Record, + env: safeEnv, }); ptyProcess.onData((data) => { diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 06df6d33..8c663f74 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -109,7 +109,7 @@ async function getAuthToken(registry: string, repo: string): Promise 1.196 const match = reclaimStr.match(/^([0-9.]+)([a-zA-Z]+)/); @@ -187,13 +187,14 @@ export class MonitorService { // Only trigger once every while? To avoid spamming, we just check if it's over limit // Let's ensure we only spam once per limit breach. We can use a local static variable. const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp'; - const lastAlert = parseInt(settings[LAST_JANITOR_ALERT_KEY] || '0', 10); + const lastAlertRaw = DatabaseService.getInstance().getSystemState(LAST_JANITOR_ALERT_KEY); + const lastAlert = parseInt(lastAlertRaw || '0', 10); const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor if (reclaimGb >= janitorLimitGb) { if (Date.now() - lastAlert > janitorCooldown) { await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`); - DatabaseService.getInstance().updateGlobalSetting(LAST_JANITOR_ALERT_KEY, Date.now().toString()); + DatabaseService.getInstance().setSystemState(LAST_JANITOR_ALERT_KEY, Date.now().toString()); } } } @@ -298,8 +299,14 @@ export class MonitorService { } try { - db.cleanupOldMetrics(24); - } catch (e) { } + const settings = db.getGlobalSettings(); + const retentionHours = parseInt(settings['metrics_retention_hours'] || '24', 10); + db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours); + const retentionDays = parseInt(settings['log_retention_days'] || '30', 10); + db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays); + } catch (e) { + console.error('MonitorService: failed to cleanup old data', e); + } } private evaluateCondition(actual: number, operator: string, threshold: number): boolean { diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index 940f3714..bb72ae71 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -157,14 +157,14 @@ export class NodeRegistry { const headers = { Authorization: `Bearer ${node.api_token}` }; try { - // Step 1: Verify auth. A 401 here means wrong token — surface that clearly. + // Step 1: Verify auth. A 401 here means wrong token - surface that clearly. const authRes = await axios.get(`${baseUrl}/api/auth/check`, { headers, timeout: 8000 }); if (authRes.status !== 200) throw new Error(`Unexpected status ${authRes.status}`); db.updateNodeStatus(node.id, 'online'); // Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing - // endpoint doesn't fail the whole test — each field falls back to '-' gracefully. + // endpoint doesn't fail the whole test - each field falls back to '-' gracefully. const [statsResult, sysResult, imagesResult] = await Promise.allSettled([ axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }), axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }), diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index ebcf0033..47b64d74 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -1,8 +1,9 @@ -import { DatabaseService } from './DatabaseService'; +import { DatabaseService, NotificationHistory } from './DatabaseService'; export class NotificationService { private static instance: NotificationService; private dbService: DatabaseService; + private broadcaster: ((notification: NotificationHistory) => void) | null = null; private constructor() { this.dbService = DatabaseService.getInstance(); @@ -15,15 +16,25 @@ export class NotificationService { return NotificationService.instance; } + /** Wire up the WebSocket push function after the WS server is initialised. */ + public setBroadcaster(fn: (notification: NotificationHistory) => void): void { + this.broadcaster = fn; + } + public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string) { - // 1. Log to history - this.dbService.addNotificationHistory({ + // 1. Log to history and get the full inserted record (with id) + const notification = this.dbService.addNotificationHistory({ level, message, timestamp: Date.now() }); - // 2. Fetch enabled agents + // 2. Push to connected browser clients via WebSocket + if (this.broadcaster) { + this.broadcaster(notification); + } + + // 3. Fetch enabled agents const agents = this.dbService.getEnabledAgents(); if (agents.length === 0) { console.log('No active notification agents found. Skipping external dispatch.'); diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index fd23c86c..5ad947ea 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -29,6 +29,7 @@ export interface Template { docs_url?: string; architectures?: string[]; stars?: number; + source?: string; repository?: { url: string; stackfile: string; @@ -40,11 +41,165 @@ export interface TemplatesResponse { templates: Template[]; } +// Static category map for LSIO apps (the LSIO API does not expose category metadata). +// Apps can belong to multiple categories. Unmapped apps fall back to ['Other']. +const LSIO_CATEGORY_MAP: Record = { + // Media Servers + 'plex': ['Media'], + 'jellyfin': ['Media'], + 'emby': ['Media'], + 'navidrome': ['Media'], + 'airsonic-advanced': ['Media'], + 'airsonic': ['Media'], + 'beets': ['Media'], + 'calibre': ['Media', 'Books'], + 'calibre-web': ['Media', 'Books'], + 'kavita': ['Media', 'Books'], + 'komga': ['Media', 'Books'], + 'mylar3': ['Media', 'Books'], + 'ubooquity': ['Media', 'Books'], + 'lazylibrarian': ['Media', 'Books'], + 'cops': ['Media', 'Books'], + 'photoprism': ['Media', 'Productivity'], + 'immich': ['Media', 'Productivity'], + 'piwigo': ['Media'], + 'lychee': ['Media'], + 'davos': ['Media'], + 'mstream': ['Media'], + 'koel': ['Media'], + 'grocy': ['Productivity'], + // *arr Automation suite + 'sonarr': ['Automation', 'Media'], + 'radarr': ['Automation', 'Media'], + 'lidarr': ['Automation', 'Media'], + 'readarr': ['Automation', 'Media'], + 'bazarr': ['Automation', 'Media'], + 'whisparr': ['Automation', 'Media'], + 'prowlarr': ['Automation'], + 'jackett': ['Automation'], + 'nzbhydra2': ['Automation'], + 'overseerr': ['Automation', 'Media'], + 'ombi': ['Automation', 'Media'], + 'requestrr': ['Automation'], + 'tautulli': ['Monitoring', 'Media'], + 'organizr': ['Automation'], + 'recyclarr': ['Automation'], + 'notifiarr': ['Automation'], + 'unpackerr': ['Automation'], + // Dashboards / Homepages + 'heimdall': ['Utilities'], + 'homer': ['Utilities'], + 'dasherr': ['Utilities'], + 'flame': ['Utilities'], + 'homarr': ['Utilities'], + 'dashdot': ['Monitoring'], + // Downloaders + 'qbittorrent': ['Downloaders'], + 'transmission': ['Downloaders'], + 'deluge': ['Downloaders'], + 'sabnzbd': ['Downloaders'], + 'nzbget': ['Downloaders'], + 'aria2': ['Downloaders'], + 'jdownloader-2': ['Downloaders'], + 'pyload-ng': ['Downloaders'], + 'rutorrent': ['Downloaders'], + 'flood': ['Downloaders'], + 'medusa': ['Automation', 'Downloaders'], + 'sickchill': ['Automation', 'Downloaders'], + // Monitoring + 'grafana': ['Monitoring'], + 'netdata': ['Monitoring'], + 'uptime-kuma': ['Monitoring'], + 'statping-ng': ['Monitoring'], + 'healthchecks': ['Monitoring'], + 'smokeping': ['Monitoring'], + 'librespeed': ['Monitoring'], + 'speedtest-tracker': ['Monitoring'], + 'scrutiny': ['Monitoring'], + 'prometheus': ['Monitoring'], + 'loki': ['Monitoring'], + 'influxdb': ['Monitoring'], + // Networking / Reverse Proxy + 'nginx': ['Networking'], + 'swag': ['Networking'], + 'letsencrypt': ['Networking'], + 'ddclient': ['Networking'], + 'duckdns': ['Networking'], + 'wireguard': ['Networking', 'Security'], + 'openvpn-as': ['Networking', 'Security'], + 'netbootxyz': ['Networking'], + 'pihole': ['Networking'], + 'unbound': ['Networking'], + 'adguardhome': ['Networking'], + 'cloudflared': ['Networking'], + 'haproxy': ['Networking'], + 'traefik': ['Networking'], + 'nginx-proxy-manager': ['Networking'], + 'fail2ban': ['Networking', 'Security'], + // Security / Auth + 'vaultwarden': ['Security'], + 'authelia': ['Security'], + 'lldap': ['Security'], + 'endlessh': ['Security'], + 'sshwifty': ['Security'], + // Development / CI + 'gitea': ['Development'], + 'code-server': ['Development'], + 'drone': ['Development'], + 'drone-runner-docker': ['Development'], + 'registry': ['Development'], + 'jenkins': ['Development'], + 'gogs': ['Development'], + 'woodpecker-ci': ['Development'], + 'gitlab': ['Development'], + 'fleet': ['Development'], + // Productivity / Self-hosted SaaS + 'nextcloud': ['Productivity'], + 'bookstack': ['Productivity', 'Documentation'], + 'dokuwiki': ['Productivity', 'Documentation'], + 'wikijs': ['Productivity', 'Documentation'], + 'paperless-ngx': ['Productivity'], + 'mealie': ['Productivity'], + 'freshrss': ['Productivity'], + 'miniflux': ['Productivity'], + 'wallabag': ['Productivity'], + 'trilium': ['Productivity'], + 'hedgedoc': ['Productivity'], + 'etherpad': ['Productivity'], + 'monica': ['Productivity'], + 'firefly-iii': ['Productivity'], + 'shlink': ['Productivity'], + 'yourls': ['Productivity'], + 'stirling-pdf': ['Productivity'], + 'syncthing': ['Productivity'], + 'tandoor': ['Productivity'], + 'linkwarden': ['Productivity'], + 'vikunja': ['Productivity'], + // Utilities / Backup + 'duplicati': ['Utilities'], + 'restic': ['Utilities'], + 'rsnapshot': ['Utilities'], + 'mysql-workbench': ['Utilities'], + 'sqlitebrowser': ['Utilities'], + 'filezilla': ['Utilities'], + 'rdesktop': ['Utilities'], + 'webtop': ['Utilities'], +}; + +function getCategoriesForApp(name: string): string[] { + return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other']; +} + export class TemplateService { private cachedTemplates: Template[] = []; private lastFetchTime: number = 0; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours + public clearCache(): void { + this.cachedTemplates = []; + this.lastFetchTime = 0; + } + public async getTemplates(): Promise { const now = Date.now(); if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) { @@ -73,6 +228,8 @@ export class TemplateService { docs_url: app.readme, architectures: app.arch, stars: app.stars, + categories: getCategoriesForApp(app.name), + source: 'linuxserver', // Map configs if available, otherwise default to empty arrays ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), volumes: (app.config?.volumes || []).map((v: any) => { @@ -91,7 +248,10 @@ export class TemplateService { }); } else { // Legacy Portainer v2 Format (Fallback for custom registries) - this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1); + // The Portainer v2 spec includes a native `categories` field - pass it through. + this.cachedTemplates = (response.data.templates || []) + .filter((t: Template) => !!t.image && t.type === 1) + .map((t: Template) => ({ ...t, source: 'custom' })); } this.lastFetchTime = now; diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts new file mode 100644 index 00000000..c8974ff4 --- /dev/null +++ b/backend/src/utils/validation.ts @@ -0,0 +1,52 @@ +import path from 'path'; + +/** + * Stack name must only contain URL-safe characters with no path separators. + * Prevents path-traversal attacks when the name is used to build filesystem paths. + */ +export const isValidStackName = (name: string): boolean => + /^[a-zA-Z0-9_-]+$/.test(name); + +/** + * Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL. + * Rejects loopback addresses to prevent SSRF against local services. + * Private/LAN IPs are allowed — users legitimately point Sencho at nodes on their LAN. + */ +export function isValidRemoteUrl( + raw: string, +): { valid: true; url: URL } | { valid: false; reason: string } { + let url: URL; + try { + url = new URL(raw); + } catch { + return { + valid: false, + reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)', + }; + } + if (!['http:', 'https:'].includes(url.protocol)) { + return { valid: false, reason: 'API URL must use http:// or https://' }; + } + // Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]' + const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i; + if (loopback.test(url.hostname)) { + return { + valid: false, + reason: 'API URL cannot point to localhost or loopback — use the actual host address', + }; + } + return { valid: true, url }; +} + +/** + * Asserts that a resolved file path stays within a given base directory. + * Returns true if the path is safe, false if it escapes the base. + */ +export function isPathWithinBase(resolvedPath: string, baseDir: string): boolean { + const normalizedBase = path.resolve(baseDir); + const normalizedPath = path.resolve(resolvedPath); + return ( + normalizedPath === normalizedBase || + normalizedPath.startsWith(normalizedBase + path.sep) + ); +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 00000000..a782440b --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + // Only run TypeScript sources — exclude the compiled dist/ output. + include: ['src/__tests__/**/*.test.ts'], + exclude: ['dist/**', 'node_modules/**'], + // Each test file gets its own worker so singletons are fresh between files. + pool: 'forks', + // Timeout generous for DB init and HTTP calls. + testTimeout: 15_000, + // Sequential within each file (DB state is shared per file). + sequence: { concurrent: false }, + }, +}); diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..5026a8bd --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,63 @@ +#!/bin/sh +set -e + +# Resolve the data directory, mirroring DatabaseService.ts logic. +DATA_DIR="${DATA_DIR:-/app/data}" + +# If running as root (the default Docker container start), fix volume ownership, +# fix Docker socket group access, then drop privileges before executing the app. +# +# This is the industry-standard pattern used by the official PostgreSQL, Redis, +# and MariaDB Docker images, and by Docker management tools like Portainer and +# Dockge that also require access to /var/run/docker.sock as a non-root user. +# +# The UID guard also ensures compatibility with strict environments like +# Kubernetes (runAsNonRoot: true) or OpenShift, where the container is forced to +# run as a random high UID. In that case both blocks are skipped and the app +# exec's directly without crashing. +if [ "$(id -u)" = '0' ]; then + + # 1. Fix data volume ownership. + # Handles host volumes previously created by root or a different UID, which + # would cause SQLITE_READONLY errors when the non-root sencho user starts. + # Only touches files with wrong user OR group (efficient on large dirs). + mkdir -p "$DATA_DIR" + find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \ + -exec chown sencho:sencho '{}' + + echo "[entrypoint] Data directory ownership ensured: $DATA_DIR" + + # 2. Fix Docker socket group access. + # The Docker socket on the host is owned by the host's docker group, whose + # GID varies by Linux distribution and does not match any group inside the + # container by default. + if [ -S /var/run/docker.sock ]; then + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock) + DOCKER_SOCK_MODE=$(stat -c '%a' /var/run/docker.sock) + echo "[entrypoint] Docker socket found: GID=$DOCKER_SOCK_GID mode=$DOCKER_SOCK_MODE" + + if [ "$DOCKER_SOCK_GID" = "0" ]; then + echo "[entrypoint] WARNING: Docker socket is root:root -- adding sencho to root group" + addgroup sencho root 2>/dev/null || true + else + if ! getent group "$DOCKER_SOCK_GID" > /dev/null 2>&1; then + addgroup -S -g "$DOCKER_SOCK_GID" docker-host + echo "[entrypoint] Created group docker-host with GID $DOCKER_SOCK_GID" + fi + DOCKER_GROUP=$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1) + addgroup sencho "$DOCKER_GROUP" 2>/dev/null || true + echo "[entrypoint] Added sencho to group '$DOCKER_GROUP' (GID $DOCKER_SOCK_GID)" + fi + else + echo "[entrypoint] WARNING: /var/run/docker.sock not found -- Docker features unavailable" + fi + + echo "[entrypoint] Dropping privileges to sencho (uid=$(id -u sencho))" + + # 3. Drop privileges. + # Replace this shell with su-exec so Node becomes PID 1 and receives + # SIGTERM/SIGINT directly. su-exec calls getgrouplist() for named users, + # so all supplementary groups added above are inherited by the process. + exec su-exec sencho "$@" +fi + +exec "$@" diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 00000000..230e9e4e --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "theme": "mint", + "name": "Sencho", + "colors": { + "primary": "#0F172A", + "light": "#3B82F6", + "dark": "#0F172A" + }, + "logo": { + "light": "/images/logo/logo-light.svg", + "dark": "/images/logo/logo-dark.svg", + "href": "/" + }, + "favicon": "/images/logo/favicon.ico", + "navigation": { + "groups": [ + { + "group": "Getting Started", + "pages": [ + "getting-started/introduction", + "getting-started/quickstart", + "getting-started/configuration" + ] + }, + { + "group": "Features", + "pages": [ + "features/overview", + "features/dashboard", + "features/stack-management", + "features/editor", + "features/resources", + "features/app-store", + "features/global-observability", + "features/host-console", + "features/multi-node", + "features/alerts-notifications" + ] + }, + { + "group": "Reference", + "pages": [ + "reference/settings" + ] + }, + { + "group": "Operations", + "pages": [ + "operations/troubleshooting", + "operations/backup" + ] + } + ] + } +} diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx new file mode 100644 index 00000000..b1900516 --- /dev/null +++ b/docs/features/alerts-notifications.mdx @@ -0,0 +1,95 @@ +--- +title: Alerts & Notifications +description: Set threshold-based alerts on container metrics and route them to Discord, Slack, or any webhook. +--- + +Sencho can watch your containers for resource anomalies and notify you when thresholds are breached. Alerts are defined per stack, and notifications are delivered through external agents you configure. + + + Notifications & Alerts settings showing Discord, Slack, and Webhook tabs + + +## How alerts work + +Sencho's monitoring service samples container metrics every minute and evaluates all defined alert rules. When a rule's condition holds true for the configured duration, a notification is dispatched. A cooldown period prevents the same alert from firing repeatedly. + +## Setting up a notification agent + +At least one agent must be enabled before alerts can be delivered. Go to **Settings → Notifications**. + +### Discord + +1. In Discord, go to your server's **Settings → Integrations → Webhooks** +2. Click **New Webhook**, choose a channel, and copy the webhook URL +3. In Sencho, open **Settings → Notifications → Discord**, paste the URL, enable the toggle, and click **Save** +4. Click **Test** to send a test message + +### Slack + +1. In Slack, go to **api.slack.com/apps**, create an app, and add the **Incoming Webhooks** feature +2. Activate it and copy the generated webhook URL for your chosen channel +3. In Sencho, open **Settings → Notifications → Slack**, paste the URL, enable the toggle, and click **Save** + +### Generic Webhook + +Any HTTP endpoint that accepts a POST with a JSON body can receive Sencho alerts. Go to **Settings → Notifications → Webhook**, enter the URL, and click **Save**. + +The payload format is: +```json +{ + "level": "warning", + "message": "cpu_percent exceeded 90% for 5 minutes on stack my-app", + "timestamp": "2026-03-22T10:00:00.000Z" +} +``` + +## Creating stack alerts + +Stack alerts are configured per-stack. Right-click a stack in the sidebar (or click the **⋮** button) and select **Alerts**. + + + Stack context menu showing the Alerts option + + +The alerts sheet shows existing rules for the stack and a form to create new ones. + +### Alert fields + +| Field | Description | +|-------|-------------| +| **Metric** | The container metric to watch | +| **Operator** | Comparison operator: `>`, `>=`, `<`, `<=`, `==` | +| **Threshold** | The value to compare against | +| **Duration (minutes)** | How long the condition must hold before firing | +| **Cooldown (minutes)** | Minimum time between repeated notifications for this rule | + +### Available metrics + +| Metric | Unit | Description | +|--------|------|-------------| +| `cpu_percent` | % | CPU usage relative to total host cores | +| `memory_mb` | MB | RSS memory used by the container | +| `memory_percent` | % | Memory used as a fraction of the host total | +| `net_rx` | bytes/s | Inbound network rate | +| `net_tx` | bytes/s | Outbound network rate | +| `restart_count` | count | Number of times the container has restarted | + +### Example: alert on high CPU + +To alert when any container in a stack uses more than 80% CPU for over 5 consecutive minutes, with a 30-minute cooldown: + +| Field | Value | +|-------|-------| +| Metric | `cpu_percent` | +| Operator | `>` | +| Threshold | `80` | +| Duration | `5` | +| Cooldown | `30` | + +## Notification history + +All dispatched notifications appear in the **notification bell** (top-right of the nav bar). Click it to see recent alerts with their level, message, and timestamp. Mark all as read or clear individual entries from there. + + + Notifications only reach external agents (Discord, Slack, Webhook) if at least one agent is enabled. Dashboard notifications appear regardless. + diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx new file mode 100644 index 00000000..ebb90546 --- /dev/null +++ b/docs/features/app-store.mdx @@ -0,0 +1,71 @@ +--- +title: App Store +description: Browse and deploy pre-configured application templates in one click. +--- + +The **App Store** tab lets you browse a curated catalogue of Docker Compose templates and deploy any of them as a new stack with environment-specific configuration — no YAML required. + + + App Store showing a grid of application templates with category filters + + +## Browsing templates + +Templates are loaded from a remote registry (configurable in **Settings → App Store**). The default registry provides 190+ self-hosted application templates. + +**Search:** Type in the search bar to filter templates by name or description in real-time. + +**Categories:** Click any category pill to narrow the list: +`Automation` · `Books` · `Development` · `Documentation` · `Downloaders` · `Media` · `Monitoring` · `Networking` · `Other` · `Product` + +Each template card shows: +- Application logo +- Name and short description +- Category tags +- Links to the GitHub repo and documentation (where available) + +## Deploying a template + +Click any template card to open the **deployment sheet** on the right side of the screen. Fill in the fields and click **Deploy**. + +### Stack name + +Pre-filled with the template name in lowercase. You can change it — the same [naming rules](/features/stack-management#creating-a-stack) apply (lowercase, hyphens, no spaces). + +### Environment variables + +Each template declares the variables it needs. Sencho pre-fills sensible defaults where available (e.g. `PUID=1000`, `PGID=1000`, `TZ` from your browser locale). + +Edit any value before deploying. You can also click **Custom Env** to add arbitrary key-value pairs not defined by the template. + +### Volumes + +For each container mount point, enter the **host path** where that data should be stored. Suggested defaults (e.g. `./config`, `./data`) are shown as placeholders. + + + Relative paths like `./config` are resolved relative to the stack directory inside your `COMPOSE_DIR`. Absolute paths map directly to the host filesystem. + + +### Ports + +For each exposed port, edit the **host port** (left side). The container port (right side) is fixed by the template and cannot be changed here — edit the compose file after deployment if needed. + +### What happens on Deploy + +1. Sencho creates a new stack directory in `COMPOSE_DIR` +2. Writes the generated `compose.yaml` and `.env` file +3. Runs `docker compose up -d` +4. On success: switches you to the Editor view for that stack +5. On failure: rolls back by running `docker compose down` and deleting the stack directory + +A toast notification confirms success or shows the error message. + +## Custom template registry + +By default Sencho uses the LinuxServer.io template registry. To use your own: + +1. Open **Settings → App Store** +2. Enter your registry URL (must serve a JSON array of template objects in Portainer v2 format) +3. Click **Save** + +To force a refresh of the cached templates, click **Refresh Cache** in the same settings panel. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx new file mode 100644 index 00000000..2869e351 --- /dev/null +++ b/docs/features/dashboard.mdx @@ -0,0 +1,60 @@ +--- +title: Dashboard +description: Real-time system stats, historical metrics, and a quick-start converter for your host machine. +--- + +The **Home** tab is the first thing you see after logging in. It shows a live snapshot of your host's health alongside container activity across all stacks. + + + Sencho dashboard showing container stats, system stats, and historical charts + + +## Container stats + +The top row summarises container state at a glance: + +| Card | What it shows | +|------|---------------| +| **Active Containers** | Running containers — broken down as `N managed · N external` | +| **Exited Containers** | Stopped or crashed containers | +| **Docker Network** | Current inbound/outbound network throughput across all containers | + +**Managed** means the container belongs to a stack in your `COMPOSE_DIR`. **External** means it exists on the Docker host but was started outside Sencho (e.g. by another Compose project or `docker run`). + +## System stats + +The second row shows host-level resource usage, polled every few seconds: + +| Card | What it shows | +|------|---------------| +| **Host CPU** | Current CPU usage percentage and core count | +| **Host RAM** | Used / total memory in GB and percentage | +| **Host Disk** | Used / total disk space for the primary mount point | + +When any value exceeds configured thresholds (set in **Settings → System Limits**), the card changes colour as a visual warning. + +## Historical metrics charts + +Two area charts display time-series data sampled at one-minute intervals, retained for up to 24 hours (configurable in **Settings → Developer**): + +- **Normalized CPU Usage** — total CPU percentage across all managed containers, normalised over all host cores +- **Normalized RAM Usage** — total memory allocated by managed containers, in GB + +The x-axis shows time labels. Hover over a data point to see the exact value at that moment. + + + Charts only show data from the moment Sencho started. If you just installed Sencho, they will be mostly empty until metrics accumulate. + + +## Convert `docker run` to Compose + +The bottom panel provides a quick converter: paste any `docker run` command and Sencho transforms it into a valid `docker-compose.yaml` snippet. + +```bash +# Example input +docker run -d --name myapp -p 8080:80 -e TZ=UTC -v /data:/app/data nginx:latest +``` + +Click **Convert** and the output YAML appears ready to copy. You can then create a new stack and paste it into the editor. + +This is useful when migrating existing containers to managed Compose stacks without having to write YAML by hand. diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx new file mode 100644 index 00000000..4ebfbd4e --- /dev/null +++ b/docs/features/editor.mdx @@ -0,0 +1,64 @@ +--- +title: Editor +description: Edit compose files and environment variables, and manage containers directly from the dashboard. +--- + +Selecting a stack opens the editor view — a split-pane layout with container management on the left and a full Monaco code editor on the right. + + + Editor view showing container panel and Monaco editor + + +## Compose file editor + +The right panel shows your `compose.yaml` with full syntax highlighting. By default the editor is **read-only** to prevent accidental changes. + +Click **Edit** to enter edit mode. Your changes are unsaved until you explicitly save them. + +### Save options + +| Button | What it does | +|--------|--------------| +| **Save** | Writes the file to disk. Does not restart any containers. | +| **Save & Deploy** | Writes the file, then immediately runs `docker compose up -d`. Use this to apply compose changes in one step. | +| **Discard** | Reverts the editor to the last saved version. Unsaved changes are lost. | + +## Environment file editor + +Click the **.env** tab to switch to the environment file editor. If your `compose.yaml` references multiple env files (via `env_file:`), a dropdown lets you select which file to edit. + +The `.env` editor has the same save/discard controls as the compose editor. Changes take effect the next time the stack is deployed. + + + Sencho reads the `env_file:` paths from your `compose.yaml` to discover available env files. If no `env_file:` is declared, a default `.env` in the stack directory is used. + + +## Container panel + +The left panel lists all containers that belong to the selected stack. Each container shows: + +- **Status badge** — `running`, `exited`, `starting`, or `unhealthy` +- **Live stats** — CPU %, RAM usage, and network I/O updated every 1–2 seconds +- **Port mappings** — host:container port pairs (if any) + +### Container actions + +Each container row has three action buttons: + +| Button | What it does | +|--------|--------------| +| **Logs** (external link icon) | Opens a live log stream modal for that container | +| **Terminal** (terminal icon) | Opens an interactive bash session inside the container | +| **Copy ID** | Copies the container ID to the clipboard | + +### Log viewer + +The log viewer streams output from a single container in real-time using Server-Sent Events. Logs auto-scroll to the bottom as new lines arrive. Close the modal to stop the stream. + +### Container terminal (exec) + +The terminal modal gives you an interactive bash shell inside the running container — equivalent to `docker exec -it bash`. It uses a full xterm.js emulator with color support and tab completion. + + + The container terminal requires the container to have `bash` (or `sh`) installed. Minimal images (e.g. Alpine-based) may need `sh` instead. + diff --git a/docs/features/global-observability.mdx b/docs/features/global-observability.mdx new file mode 100644 index 00000000..4e396666 --- /dev/null +++ b/docs/features/global-observability.mdx @@ -0,0 +1,58 @@ +--- +title: Global Observability +description: A unified, searchable log stream from every container across all your stacks. +--- + +The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place — with filtering to focus on what matters. + + + Global Observability view showing real-time log lines from multiple containers + + +## Log format + +Each line shows: + +- **Timestamp** — when the log line was emitted +- **Stack name** — the Compose stack the container belongs to (colour-coded) +- **Container name** — the specific container +- **Level** — `INFO`, `WARN`, or `ERROR` (where detectable) +- **Message** — the raw log output + +## Streaming modes + +Sencho supports two modes for fetching logs, switchable via the **Developer mode** toggle: + +| Mode | How it works | Best for | +|------|-------------|----------| +| **Standard** (default) | Polls all containers every N seconds (configurable in Settings → Developer) | General use; lower overhead | +| **Developer mode** | Server-Sent Events stream; logs arrive as they are emitted | Debugging; watching a specific event in real-time | + +The default polling interval is 5 seconds. You can change it to 1, 3, 5, or 10 seconds in **Settings → Developer → Global Logs Refresh Rate**. + +## Filtering logs + +Use the controls above the log panel to narrow what you see: + +| Control | What it does | +|---------|-------------| +| **Stack filter** | Multi-select dropdown — show only logs from chosen stacks | +| **Stream** | `ALL` / `STDOUT` / `STDERR` — filter by output stream | +| **Search** | Full-text filter on the message field (case-insensitive) | + +Filters combine — you can show only `STDERR` from a specific stack while searching for a keyword. + +## Controls + +| Button | What it does | +|--------|-------------| +| **Clear** | Clears the current log buffer in the UI (does not delete logs from Docker) | +| **Auto-scroll** toggle | When enabled, the view scrolls to the bottom as new lines arrive | + +## Capacity limits + +To avoid browser memory issues, the log view keeps a maximum of **2,000 entries** in memory at a time. Older entries are dropped as new ones arrive. For deeper investigation, use the [per-container log viewer](/features/editor#log-viewer) in the Editor tab, or `docker compose logs` directly from the [Host Console](/features/host-console). + + + Log retention on the backend is controlled by **Settings → Developer → Log Retention Days** (default: 30 days). This affects historical logs stored in Sencho's database, not the live stream. + diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx new file mode 100644 index 00000000..6108b2e3 --- /dev/null +++ b/docs/features/host-console.mdx @@ -0,0 +1,54 @@ +--- +title: Host Console +description: An interactive terminal on your host OS, directly in the browser — no SSH required. +--- + +The **Console** tab opens a full interactive terminal session on the machine running Sencho. It behaves exactly like an SSH session, but without needing an SSH server, client, or key management. + + + Host Console showing a PowerShell prompt inside the Sencho container working directory + + +## What it is + +The host console spawns a real PTY (pseudo-terminal) process on the Sencho host using `node-pty`. Your keystrokes are sent over a WebSocket and the terminal output is streamed back in real-time. The terminal emulator is [xterm.js](https://xtermjs.org/), the same engine used by VS Code's built-in terminal. + +Features: +- Full colour and cursor support +- Tab completion (via the host shell) +- Scrollback buffer (10,000 lines) +- Automatic terminal resizing when you resize the browser window +- Copy and paste + +## Opening the console + +Click **Console** in the top navigation bar. The session starts immediately in the stack's working directory if a stack is selected, otherwise in the `COMPOSE_DIR` root. + +The header shows the connection status (**Connected** in green) and which node the console is attached to. + +Click **Close Console** to end the session and terminate the shell process on the host. + +## Shell type + +The shell depends on the host OS: +- **Linux/macOS hosts:** `bash` or `sh` +- **Windows hosts (Docker Desktop):** PowerShell (as shown in the screenshot — the console opens inside the Sencho container's Windows environment) + +## Security model + +The host console has a stricter authentication requirement than other features: + +- Requires a valid **browser session** (httpOnly cookie). Node-proxy tokens used for multi-node communication are explicitly blocked. +- Each session is issued a short-lived **console token** (60-second TTL) before the WebSocket is established. +- Because the console gives full shell access to the host, you should secure your Sencho instance with HTTPS and a strong password if it is exposed to a network. + + + The host console provides unrestricted shell access to the machine running Sencho. Do not expose Sencho on a public network without HTTPS and strong authentication. + + +## Common uses + +- Inspecting files in your `COMPOSE_DIR` without leaving the browser +- Running `docker compose logs --follow` or `docker ps` directly +- Editing files with `nano` or `vim` for quick fixes +- Running maintenance scripts or one-off commands on the host diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx new file mode 100644 index 00000000..17d44373 --- /dev/null +++ b/docs/features/multi-node.mdx @@ -0,0 +1,80 @@ +--- +title: Multi-Node Management +description: Connect multiple Sencho instances and manage all your servers from a single dashboard. +--- + +Sencho's multi-node feature lets you manage Docker Compose stacks on multiple servers — all from the same browser tab. Each server runs its own Sencho instance, and your primary instance acts as a transparent proxy to the others. + + + Node Manager showing a local and a remote node, both Online + + +## How it works + +There is no central server. Each Sencho instance manages its own host independently. When you select a remote node, your browser's API calls are proxied through your local Sencho instance to the remote one, authenticated by a long-lived Bearer token. No SSH. No shared Docker sockets. + +## The local node + +Your primary Sencho installation is always listed as **Local**. It is the default node, marked with a star, and cannot be deleted. All operations on the local node run directly against the host's Docker socket. + +## Adding a remote node + +### Step 1: Generate a token on the remote machine + +On the **remote** Sencho instance (the server you want to add), open **Settings → Nodes** and click **Generate Token**. Copy the generated token — you'll only see it once. + + + The token is a long-lived JWT scoped to `node_proxy`. Anyone with this token can fully control that Sencho instance, so treat it like a password. + + +### Step 2: Add the node on your primary instance + +On your **primary** Sencho instance, open **Settings → Nodes** and click **+ Add Node**. Fill in: + +| Field | Description | +|-------|-------------| +| **Name** | A display name (e.g. `prod-server`, `media-box`) | +| **Type** | Select **Remote** | +| **Sencho API URL** | The full HTTP/HTTPS URL of the remote instance (e.g. `http://192.168.1.20:3001`) | +| **API Token** | The token you generated in Step 1 | + +Click **Create**. Sencho immediately tests the connection and shows the result. + +### Step 3: Verify connectivity + +A successful connection shows the remote node as **Online** with a green badge. If it shows **Offline** or **Unknown**, check: +- The remote Sencho instance is running and reachable from your primary host +- The API URL is correct (include the port if non-standard) +- The token was copied correctly without extra whitespace + +Click the **wifi icon** (test connection) on any node row at any time to re-check status. + +## The 1:1 path rule for remote nodes + +The Compose directory path matters on remote nodes too. When you register a remote node, its `COMPOSE_DIR` is whatever that remote instance was configured with. Make sure the remote Sencho's `COMPOSE_DIR` follows the [1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule) on that remote host. + +## Switching between nodes + +The **node switcher** dropdown in the top-left of the sidebar shows the currently active node. Click it to switch to any registered node. All views — dashboard stats, stack list, editor, resources, logs — immediately reflect the selected node. + + + Node switcher dropdown and node list + + +Node status indicators: + +| Indicator | Meaning | +|-----------|---------| +| Green dot | Node is reachable and responding | +| Red dot | Node is unreachable | +| Gray dot | Status not yet checked | + +## Editing and deleting nodes + +Click the **pencil icon** on any remote node row to edit its name, URL, or token. Click the **trash icon** to remove it. The local node cannot be edited or deleted. + +## Security considerations + +- Node tokens grant full control over the remote Sencho instance. Rotate them if compromised via Settings → Nodes → Generate Token (the old token is invalidated). +- Use HTTPS between instances in production to prevent token interception. +- The host console and container exec terminals are blocked for node-proxy tokens — interactive shell access always requires a real browser session on that instance. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx new file mode 100644 index 00000000..04fc698d --- /dev/null +++ b/docs/features/overview.mdx @@ -0,0 +1,48 @@ +--- +title: Features Overview +description: A high-level tour of everything Sencho can do. +--- + +## Stack management + +Deploy, start, stop, restart, and remove Docker Compose stacks through a point-and-click interface. Edit `compose.yaml` and `.env` files directly in the built-in Monaco editor with syntax highlighting. [Learn more →](/features/stack-management) + +## Editor + +Full in-browser code editor for your Compose and environment files. Toggle edit mode, save to disk, or save and deploy in one step. Per-container live stats and an interactive bash exec terminal live alongside the editor. [Learn more →](/features/editor) + +## Multi-node support + +Add remote Sencho instances as nodes. All dashboard operations — stack management, logs, stats — work identically whether you're targeting your local machine or a server on the other side of the world. Uses a transparent HTTP proxy model; no SSH or shared Docker sockets required. [Learn more →](/features/multi-node) + +## Real-time logs & stats + +Stream container logs and resource metrics (CPU, memory, network I/O) live in the browser via WebSocket and Server-Sent Events connections. The Home dashboard shows historical CPU and RAM charts over the last 24 hours. + +## Global observability + +The **Logs** view aggregates output from all containers across all stacks into a single scrollable stream. Filter by stack, log level (stdout/stderr), or search for keywords. Switch to developer mode for real-time SSE streaming. + +## Resources hub + +View and manage all Docker images, volumes, and networks. Resources are classified as: + +| Label | Meaning | +|-------|---------| +| **Managed** | Owned by a Sencho stack | +| **External** | Part of another Compose project | +| **Unused / Reclaimable** | Safe to prune | + +Run scoped prune operations to clean up Sencho-managed resources only, or target all Docker resources when needed. + +## App Store + +Browse 190+ pre-configured application templates. Filter by category (Media, Automation, Development, etc.), configure environment variables, volumes, and ports, and deploy with a single click. + +## Host console + +Open an interactive terminal on the host OS directly in the browser — full xterm.js emulation with color support. No SSH client required. + +## Alerts & notifications + +Configure threshold-based alerts (CPU, memory, network, restart count) per stack. Route notifications to Discord, Slack, or any generic webhook endpoint. [Learn more →](/features/alerts-notifications) diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx new file mode 100644 index 00000000..96c305c0 --- /dev/null +++ b/docs/features/resources.mdx @@ -0,0 +1,75 @@ +--- +title: Resources Hub +description: Browse, filter, and clean up Docker images, volumes, networks, and unmanaged containers. +--- + +The **Resources** tab gives you a full view of everything Docker is storing on your host, broken down by type and ownership. + + + Resources Hub showing disk footprint, quick clean panel, and images table + + +## Docker disk footprint + +The stacked bar at the top visualises how your Docker disk usage is distributed: + +| Segment | Meaning | +|---------|---------| +| **Sencho Managed** (green) | Images used by stacks in your `COMPOSE_DIR` | +| **External Projects** (orange) | Images used by Docker projects outside Sencho | +| **Reclaimable** (gray) | Unused images and dangling layers safe to delete | + +Click any segment to automatically filter the tabs below to that category. + +## Quick Clean panel + +Four prune buttons let you reclaim disk space immediately. By default they operate on **Sencho-managed resources only** — they will not touch external Docker projects. + +| Button | What it removes | +|--------|----------------| +| **Prune Unused Images** | Images with no running containers in Sencho stacks | +| **Prune Unused Volumes** | Volumes not attached to any Sencho container | +| **Prune Dead Networks** | Networks not connected to any Sencho container | +| **Purge Unmanaged Containers** | Containers Sencho doesn't recognise (started outside it) | + +Each button has a **⋮ More options** menu that lets you target **all Docker resources** instead of Sencho-only. Use this carefully — it can affect other Compose projects running on the same host. + +A confirmation dialog appears before any destructive operation, showing a summary of what will be removed. + +## Resource tabs + +### Images + +Lists all Docker images on the host with their ID, repository tag, size, and status. + +**Filter buttons:** `All` · `Managed` · `External` + +**Status badges:** +- `In Use` + stack name — image is actively used by a running container +- `Unused` — image has no running containers; safe to delete + +Click the trash icon on any row to delete an individual image. Sencho will warn you if the image is in use. + +### Volumes + +Lists all Docker volumes. Columns: name, driver, mount point, size, and managed status. + +**Filter buttons:** `All` · `Managed` · `External` + + + Deleting a volume is permanent. Any data stored in it will be lost. Always back up important volume data before pruning. + + +### Networks + +Lists all Docker networks. Columns: name, driver, scope (`local`, `global`, `swarm`), and managed status. + +**Filter buttons:** `All` · `Managed` · `External` · `System` + +System networks (like `bridge`, `host`, `none`) are shown but cannot be deleted. + +### Unmanaged + +Lists containers running on the host that are not part of any Sencho-managed stack. This includes containers started with `docker run`, or Compose projects outside your `COMPOSE_DIR`. + +This view is useful for identifying orphaned containers after a failed deployment or after moving stacks in and out of `COMPOSE_DIR`. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx new file mode 100644 index 00000000..c91c5661 --- /dev/null +++ b/docs/features/stack-management.mdx @@ -0,0 +1,71 @@ +--- +title: Stack Management +description: Create, deploy, control, and remove Docker Compose stacks. +--- + +A **stack** in Sencho is a Docker Compose project: a directory inside your `COMPOSE_DIR` that contains at least a `compose.yaml` (or `docker-compose.yml`) file. Sencho automatically discovers every subdirectory as a stack. + + + Create New Stack dialog + + +## Creating a stack + +Click **Create Stack** in the left sidebar. Enter a name and click **Create**. + +**Naming rules:** +- Lowercase letters, numbers, and hyphens only (e.g. `my-app`, `nextcloud`) +- No spaces or special characters +- Must be unique — duplicates are rejected + +Sencho creates a new directory inside `COMPOSE_DIR` with a blank `compose.yaml` file. You'll land in the editor automatically. + +## The stack list + +All discovered stacks appear in the left sidebar. Each shows a color-coded status dot: + +| Color | Meaning | +|-------|---------| +| Green | All containers running | +| Red | One or more containers exited | +| Gray | No containers / status unknown | + +Use the **search box** above the list to filter stacks by name. + +## Deploying a stack + +Select a stack and click **Deploy** in the stack header. This runs `docker compose up -d` — pulling images if needed and creating or recreating containers. + + + Stack editor with control buttons + + +## Controlling a running stack + +The stack header exposes four actions: + +| Button | Command | What it does | +|--------|---------|--------------| +| **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | +| **Restart** | `docker compose restart` | Restarts all containers in the stack. | +| **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | +| **Delete** | `down` + removes files | Stops and removes containers, then deletes the stack directory. | + + + **Delete** is irreversible. It removes the stack directory — including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting. + + +## Stack context menu + +Right-click or use the **⋮** button on any stack in the sidebar to access: + + + Stack context menu showing Alerts option + + +- **Alerts** — configure metric-based alerting rules for this stack +- **Check for updates** — manually trigger an image update check + +## Converting a `docker run` command + +If you have an existing `docker run` command and want to turn it into a Compose stack, go to the **Home** tab and paste it into the "Convert Docker Run to Compose" field. Sencho converts it to YAML that you can save as a new stack. diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx new file mode 100644 index 00000000..751e4ffc --- /dev/null +++ b/docs/getting-started/configuration.mdx @@ -0,0 +1,161 @@ +--- +title: Configuration +description: Environment variables, volume mounts, and the 1:1 path rule. +--- + +Sencho is configured entirely through environment variables and Docker volume mounts. There is no config file to edit inside the container. + +## Required environment variables + +| Variable | Description | +|----------|-------------| +| `JWT_SECRET` | Secret key used to sign session tokens. Use a long, random string (32+ characters). Changing this invalidates all active sessions. | +| `COMPOSE_DIR` | Absolute path to the directory that contains your Compose stacks. Every subdirectory inside becomes a stack in Sencho. | + +## Optional environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | Port the Sencho HTTP server listens on. | +| `DATA_DIR` | `/app/data` | Directory where Sencho stores its SQLite database, node registry, and cached metrics. | +| `NODE_ENV` | `production` | Set automatically in the Docker image. Only change this for local development. | + +## Required volume mounts + +### Docker socket + +Sencho needs access to the Docker daemon to manage containers: + +```yaml +volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +### Data directory + +Sencho's database persists all your settings, nodes, alerts, and metrics history. Mount a named volume or host path so it survives container restarts: + +```yaml +volumes: + - ./sencho-data:/app/data +``` + + + Without a persistent data mount, Sencho will lose all configuration — including registered nodes, alerts, and settings — every time the container restarts. + + +### Compose directory — the 1:1 path rule + + + This is the most common source of deployment problems. Read carefully. + + +When Sencho runs `docker compose up`, it does so on your **host machine**. Docker resolves relative volume paths in your Compose files relative to the **host** path of the stack directory — not the path inside the Sencho container. + +**The rule:** Mount your Compose directory at the **exact same path** inside the container as it exists on your host. + +```yaml +# ✅ Correct — host path matches container path +volumes: + - /home/boris/docker:/home/boris/docker +environment: + - COMPOSE_DIR=/home/boris/docker +``` + +```yaml +# ❌ Wrong — paths differ, relative volumes will break +volumes: + - /home/boris/docker:/app/compose +environment: + - COMPOSE_DIR=/app/compose +``` + +If you use a simple path like `/opt/compose` on your host, mount it at `/opt/compose` in the container: + +```yaml +volumes: + - /opt/compose:/opt/compose +environment: + - COMPOSE_DIR=/opt/compose +``` + +## Full docker-compose.yml example + +```yaml +services: + sencho: + image: saelix/sencho:latest + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./sencho-data:/app/data + - /opt/compose:/opt/compose # 1:1 path rule + environment: + - JWT_SECRET=your-long-random-secret-here + - COMPOSE_DIR=/opt/compose + - DATA_DIR=/app/data +``` + +## Optional: global environment file + +If your Compose stacks share common variables (e.g. `PUID`, `PGID`, `TZ`), you can pass an `env_file` to the Sencho container so those variables are available in the host environment when `docker compose` runs: + +```yaml +services: + sencho: + image: saelix/sencho:latest + env_file: + - /opt/compose/globals.env # shared vars for all stacks + environment: + - JWT_SECRET=your-secret + - COMPOSE_DIR=/opt/compose + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/compose:/opt/compose + - ./sencho-data:/app/data +``` + +## Reverse proxy setup + +Sencho works behind any reverse proxy. The only requirement is that WebSocket connections are forwarded correctly (used for live logs, container terminals, and the host console). + +### Nginx + +```nginx +server { + listen 80; + server_name sencho.yourdomain.com; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + + # WebSocket support + 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_read_timeout 3600s; + } +} +``` + +### Traefik (Docker labels) + +```yaml +labels: + - "traefik.enable=true" + - "traefik.http.routers.sencho.rule=Host(`sencho.yourdomain.com`)" + - "traefik.http.services.sencho.loadbalancer.server.port=3000" +``` + + + Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends. No extra configuration needed. + + +## First boot + +After starting Sencho, open it in your browser. If no admin account exists yet, you'll be taken to a setup screen to create one. This only appears once — subsequent visits go directly to the login page. diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx new file mode 100644 index 00000000..8e719c6d --- /dev/null +++ b/docs/getting-started/introduction.mdx @@ -0,0 +1,32 @@ +--- +title: Introduction +description: What Sencho is and why you might want it. +--- + +Sencho is a self-hosted Docker Compose management dashboard. It gives you a clean web UI to deploy, manage, and monitor your Docker Compose stacks — locally or across multiple remote servers — without touching a terminal. + + + Sencho dashboard showing system stats and container metrics + + +## Key concepts + +- **Stacks** — a Docker Compose project living in your `COMPOSE_DIR`. Sencho treats each subdirectory as a stack. +- **Nodes** — a Sencho instance. Your local machine is always the default node. Add remote nodes by pointing Sencho at another Sencho instance's API URL. +- **Resources** — images, volumes, and networks that belong to your stacks (managed) or exist outside them (external/unused). + + + Sencho never accesses remote servers directly via SSH or Docker TCP. Remote management works by proxying API requests to another running Sencho instance. + + +## What you can do + +- **Deploy and control stacks** — create, start, stop, restart, and delete Compose stacks with one click +- **Edit files in-browser** — full Monaco editor for `compose.yaml` and `.env` files +- **Monitor in real-time** — live CPU, RAM, disk, and network stats with historical charts +- **Stream logs** — tail container logs individually or aggregate all stacks in one view +- **Manage resources** — browse, filter, and prune Docker images, volumes, and networks +- **Deploy from the App Store** — one-click deployment from a curated template registry +- **Run a host console** — interactive terminal on the host OS directly in the browser +- **Set alerts** — threshold-based notifications via Discord, Slack, or any webhook +- **Manage multiple servers** — add remote Sencho instances as nodes and switch between them seamlessly diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx new file mode 100644 index 00000000..a498cdaf --- /dev/null +++ b/docs/getting-started/quickstart.mdx @@ -0,0 +1,38 @@ +--- +title: Quickstart +description: Get Sencho running in under five minutes. +--- + +## Prerequisites + +- Docker and Docker Compose installed on the host +- A directory where your Compose projects live (e.g. `/opt/compose`) + +## Run with Docker + +```bash +docker run -d \ + --name sencho \ + -p 3000:3000 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /opt/compose:/app/compose \ + -v sencho_data:/app/data \ + -e JWT_SECRET=change-me \ + ghcr.io/ansоcode/sencho:latest +``` + +Open `http://localhost:3000` in your browser. On first boot you'll be prompted to create an admin account. + + + Replace `/opt/compose` with the path to your Compose projects directory. Every subdirectory inside it becomes a stack in Sencho. + + +## Important: the 1:1 path rule + +The `-v /opt/compose:/app/compose` mount above uses a simplified path for illustration. In practice, you must mount your compose directory at the **same path** inside and outside the container. See the [Configuration guide](/getting-started/configuration#compose-directory-the-11-path-rule) for details — this is the most common setup mistake. + +## Next steps + +- [Configuration](/getting-started/configuration) — full environment variable reference, reverse proxy setup +- [Stack Management](/features/stack-management) — create and deploy your first stack +- [Multi-Node](/features/multi-node) — add a remote server to manage from this dashboard diff --git a/docs/images/alerts-notifications/notifications-settings.png b/docs/images/alerts-notifications/notifications-settings.png new file mode 100644 index 00000000..776d9315 Binary files /dev/null and b/docs/images/alerts-notifications/notifications-settings.png differ diff --git a/docs/images/app-store/app-store-overview.png b/docs/images/app-store/app-store-overview.png new file mode 100644 index 00000000..9cfb6cdf Binary files /dev/null and b/docs/images/app-store/app-store-overview.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 00000000..2aea2d0f Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/dashboard/dashboard-overview.png b/docs/images/dashboard/dashboard-overview.png new file mode 100644 index 00000000..bd3d2121 Binary files /dev/null and b/docs/images/dashboard/dashboard-overview.png differ diff --git a/docs/images/editor/editor-overview.png b/docs/images/editor/editor-overview.png new file mode 100644 index 00000000..cc1a2105 Binary files /dev/null and b/docs/images/editor/editor-overview.png differ diff --git a/docs/images/global-observability/global-observability-overview.png b/docs/images/global-observability/global-observability-overview.png new file mode 100644 index 00000000..82e6ca2f Binary files /dev/null and b/docs/images/global-observability/global-observability-overview.png differ diff --git a/docs/images/host-console/host-console-overview.png b/docs/images/host-console/host-console-overview.png new file mode 100644 index 00000000..5d776562 Binary files /dev/null and b/docs/images/host-console/host-console-overview.png differ diff --git a/docs/images/login.png b/docs/images/login.png new file mode 100644 index 00000000..e03872ab Binary files /dev/null and b/docs/images/login.png differ diff --git a/docs/images/multi-node/node-manager.png b/docs/images/multi-node/node-manager.png new file mode 100644 index 00000000..e8dbb496 Binary files /dev/null and b/docs/images/multi-node/node-manager.png differ diff --git a/docs/images/resources.png b/docs/images/resources.png new file mode 100644 index 00000000..6c3ea589 Binary files /dev/null and b/docs/images/resources.png differ diff --git a/docs/images/resources/resources-overview.png b/docs/images/resources/resources-overview.png new file mode 100644 index 00000000..77189b2d Binary files /dev/null and b/docs/images/resources/resources-overview.png differ diff --git a/docs/images/settings/settings-overview.png b/docs/images/settings/settings-overview.png new file mode 100644 index 00000000..898686dc Binary files /dev/null and b/docs/images/settings/settings-overview.png differ diff --git a/docs/images/stack-management/create-stack-dialog.png b/docs/images/stack-management/create-stack-dialog.png new file mode 100644 index 00000000..029b246e Binary files /dev/null and b/docs/images/stack-management/create-stack-dialog.png differ diff --git a/docs/images/stack-management/stack-context-menu.png b/docs/images/stack-management/stack-context-menu.png new file mode 100644 index 00000000..4464e345 Binary files /dev/null and b/docs/images/stack-management/stack-context-menu.png differ diff --git a/docs/images/stacks.png b/docs/images/stacks.png new file mode 100644 index 00000000..8e37be80 Binary files /dev/null and b/docs/images/stacks.png differ diff --git a/docs/operations/backup.mdx b/docs/operations/backup.mdx new file mode 100644 index 00000000..a42041cf --- /dev/null +++ b/docs/operations/backup.mdx @@ -0,0 +1,139 @@ +--- +title: Backup & Restore +description: What to back up, how to restore it, and how to migrate Sencho to a new host. +--- + +Sencho stores all its state in two places: the **data directory** (SQLite database) and your **compose directory** (your actual stack files). Both need to be backed up for a complete recovery. + +--- + +## What to back up + +### 1. Data directory (`DATA_DIR`) + +Default path: `/app/data` inside the container, mapped to wherever you mounted it on the host (e.g. `./sencho-data`). + +Contains: +- `sencho.db` — SQLite database with all settings, nodes, alerts, metrics history, and notification history + +This single file is everything Sencho knows about itself. Back it up and you can fully restore any Sencho installation. + +### 2. Compose directory (`COMPOSE_DIR`) + +The directory containing your stack subdirectories — your `compose.yaml` files, `.env` files, and any bind-mounted config files stored there. + +This is your actual application data. It lives entirely outside Sencho and you almost certainly already have it on a schedule, but include it in any Sencho backup plan. + +--- + +## Backing up + +### Simple file copy + +```bash +# Stop Sencho to ensure the SQLite WAL is flushed (recommended but not strictly required) +docker stop sencho + +# Copy the data directory +cp -r /path/to/sencho-data /path/to/backup/sencho-data-$(date +%Y%m%d) + +# Copy compose stacks +cp -r /opt/compose /path/to/backup/compose-$(date +%Y%m%d) + +# Restart +docker start sencho +``` + +### SQLite online backup (without stopping) + +SQLite supports hot backups via its `.backup` command. This is safe to run while Sencho is running: + +```bash +sqlite3 /path/to/sencho-data/sencho.db ".backup '/path/to/backup/sencho.db'" +``` + +### Automated daily backup (cron example) + +```cron +0 3 * * * sqlite3 /path/to/sencho-data/sencho.db ".backup '/backups/sencho-$(date +\%Y\%m\%d).db'" && find /backups -name "sencho-*.db" -mtime +30 -delete +``` + +This backs up the database at 3 AM daily and deletes backups older than 30 days. + +--- + +## Restoring + +### Restore from backup + +1. Stop Sencho: + ```bash + docker stop sencho + ``` + +2. Replace the data directory with your backup: + ```bash + rm -rf /path/to/sencho-data/* + cp /path/to/backup/sencho.db /path/to/sencho-data/sencho.db + ``` + +3. Restore your compose directory if needed: + ```bash + cp -r /path/to/backup/compose /opt/compose + ``` + +4. Start Sencho: + ```bash + docker start sencho + ``` + +Sencho will read the restored database and resume with all your previous settings, nodes, and alert rules intact. + +--- + +## Migrating to a new host + +### Step 1: Prepare the new host + +Install Docker and Docker Compose on the new machine. Create the same directory structure you use for your compose files, following the [1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule). + +### Step 2: Copy data + +Transfer your backup files to the new host: + +```bash +scp -r /path/to/sencho-data newhost:/path/to/sencho-data +scp -r /opt/compose newhost:/opt/compose +``` + +### Step 3: Deploy Sencho on the new host + +Use the same `docker-compose.yml` you used on the old host (with the same `COMPOSE_DIR` and `DATA_DIR` paths): + +```bash +docker compose up -d +``` + +### Step 4: Update remote node references + +If other Sencho instances were pointing to your old host as a remote node, update their node config to use the new host's IP or hostname. Generate a new API token on the restored instance and distribute it. + +### Step 5: Verify + +- Log in and confirm your stacks, nodes, and alerts are all present +- Check that at least one stack deploys correctly +- Verify the node switcher shows the expected nodes with green status + +--- + +## What is NOT backed up by this process + +| Item | Location | Notes | +|------|----------|-------| +| Container data volumes | Wherever each stack's volumes are mounted on the host | Back these up separately per-application | +| Actual container images | Docker image cache | These are re-pulled on next deploy — no backup needed | +| Sencho logs (docker logs) | Container stdout | Not persisted beyond container lifetime | + + + Sencho does not currently have a built-in backup scheduler or export function. The approaches above use standard OS tools and SQLite's own backup mechanism. + diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx new file mode 100644 index 00000000..8a1feb1d --- /dev/null +++ b/docs/operations/troubleshooting.mdx @@ -0,0 +1,163 @@ +--- +title: Troubleshooting +description: Solutions to the most common Sencho setup and runtime problems. +--- + +## Containers won't start after deploy + +**Symptom:** You click Deploy and containers immediately exit or never appear. + +**Check:** Open the [Host Console](/features/host-console) and run: + +```bash +docker compose -f /path/to/your/stack/compose.yaml logs +``` + +The most common causes: + +- **Missing environment variable** — a required variable in your `.env` file is empty or has the wrong name. +- **Port already in use** — another container or host process is bound to the same port. Change the host port in the compose file. +- **Volume path does not exist** — a bind-mount path on the host doesn't exist yet. Create the directory manually. + +--- + +## The 1:1 path rule — volumes resolve to wrong paths + +**Symptom:** Stacks deploy but relative volume paths (e.g. `./config:/config`) point to the wrong location inside the container, or `docker compose` exits with a path error. + +**Cause:** Your `COMPOSE_DIR` is mounted at a different path inside the Sencho container than it has on the host. + +**Fix:** Your compose volume mount and `COMPOSE_DIR` environment variable must use the **same absolute path** on both sides: + +```yaml +# docker-compose.yml for Sencho itself +volumes: + - /opt/docker:/opt/docker # same path inside and outside +environment: + - COMPOSE_DIR=/opt/docker +``` + +See [Configuration — the 1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule) for a full explanation. + +--- + +## "Permission denied" on the Docker socket + +**Symptom:** Sencho starts but shows errors accessing Docker, or the stack list is empty even though containers exist. + +**Cause:** The Sencho container cannot read `/var/run/docker.sock`. + +**Fix:** Ensure the socket is mounted: + +```yaml +volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +On Linux, the Docker socket is owned by the `docker` group. The Sencho entrypoint detects the socket's GID automatically and adds the internal `sencho` user to the matching group. If you see permission errors despite a correct mount, check that the socket file is readable: + +```bash +ls -la /var/run/docker.sock +# Expected: srw-rw---- 1 root docker ... +``` + +If the group is not `docker`, the auto-detection still works — Sencho reads the GID from the socket file at startup. + +--- + +## Login page shows "Something went wrong" + +**Symptom:** You enter credentials and get a generic error instead of being logged in. + +**Possible causes and fixes:** + +| Cause | Fix | +|-------|-----| +| `JWT_SECRET` is not set or is empty | Set a non-empty value for `JWT_SECRET` in your environment | +| Container restarted and session cookie is stale | Clear browser cookies for the Sencho domain and try again | +| Rate limit triggered (5 failed attempts in 15 min) | Wait 15 minutes, or restart the container to reset the limiter | + +--- + +## WebSocket connections fail (logs/console not streaming) + +**Symptom:** The log viewer or host console shows a spinner that never resolves, or you see "Disconnected" immediately after connecting. + +**Cause:** A reverse proxy is not forwarding WebSocket upgrade headers. + +**Fix:** Add WebSocket support to your proxy config: + +```nginx +# Nginx +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +proxy_read_timeout 3600s; +``` + +Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends — no extra config needed. + +--- + +## Remote node shows "Offline" or "Unknown" + +**Symptom:** A node you added shows a red or gray status dot. + +**Checks in order:** + +1. **Is the remote Sencho instance running?** SSH to that machine and verify. +2. **Is the API URL correct?** It must include the protocol and port (e.g. `http://192.168.1.20:3001`). Open it in a browser — you should see a JSON response from `/api/health`. +3. **Is the token correct?** Tokens are long JWT strings. Even one missing character will cause auth to fail. Regenerate the token on the remote instance and update the node config. +4. **Is there a firewall blocking the port?** The primary Sencho host must be able to reach the remote host's Sencho port. + +Click the **wifi icon** on the node row to re-test connectivity after making changes. + +--- + +## Forgotten admin password + +Sencho has no password recovery flow. To reset the password: + +1. Stop the Sencho container +2. Connect to the SQLite database directly: + +```bash +sqlite3 /path/to/data/sencho.db +``` + +3. Delete the existing credentials so Sencho re-enters first-boot setup mode: + +```sql +DELETE FROM global_settings WHERE key IN ('auth_username', 'auth_password_hash', 'auth_jwt_secret'); +``` + +4. Restart the container — the setup screen will appear on next visit. + + + This resets authentication entirely. All active sessions become invalid. Your stacks, nodes, and alert rules are not affected. + + +--- + +## Checking the health endpoint + +Sencho exposes a health endpoint for monitoring and container health checks: + +```bash +curl http://localhost:3000/api/health +# {"status":"ok","uptime":12345.67} +``` + +A `200` response confirms the backend is running. Use this endpoint in your uptime monitor or load balancer health check. + +--- + +## Getting logs from the Sencho container itself + +```bash +docker logs sencho +# or, to follow: +docker logs -f sencho +``` + +The backend logs all route errors and service failures to stdout. This is the first place to look when the UI shows an error with no useful message. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx new file mode 100644 index 00000000..9ca2c9b7 --- /dev/null +++ b/docs/reference/settings.mdx @@ -0,0 +1,126 @@ +--- +title: Settings Reference +description: Complete reference for every option in the Sencho Settings Hub. +--- + +Open the Settings Hub by clicking **Settings** in the top navigation bar. The left sidebar lists all available sections. + + + Settings Hub showing the Account tab and the full section sidebar + + +--- + +## Account + +**Scope:** Global (applies to this Sencho instance, not per-node) + +Change the admin account password. All three fields are required. + +| Field | Description | +|-------|-------------| +| **Current Password** | Your existing password for verification | +| **New Password** | Must be at least 6 characters | +| **Confirm New Password** | Must match New Password | + +Click **Update Password** to apply. The change takes effect immediately; existing sessions remain valid. + +--- + +## System Limits + +**Scope:** Per-node (applies to the currently selected node) + +Configure resource thresholds that trigger visual warnings on the dashboard stat cards. These are display thresholds only — Sencho does not throttle or kill containers when limits are reached. + +| Setting | Range | Description | +|---------|-------|-------------| +| **Host CPU Limit** | 1–100% | CPU percentage above which the CPU card turns orange/red | +| **Host RAM Limit** | 1–100% | RAM percentage above which the RAM card turns orange/red | +| **Host Disk Limit** | 1–100% | Disk percentage above which the Disk card turns orange/red | +| **Docker Janitor Threshold** | ≥ 0 GB | Minimum free disk space to maintain. When free space falls below this value, a warning is shown. Set to `0` to disable. | +| **Global Crash Alerts** | On / Off | When enabled, Sencho sends a notification whenever any managed container exits unexpectedly | + +Click **Save** to apply. An unsaved-changes indicator appears when you have edits pending. + +--- + +## Notifications + +**Scope:** Global + +Configure external destinations for alert notifications. Three agent types are supported, each on its own sub-tab: **Discord**, **Slack**, and **Webhook**. + +For each agent: + +| Field | Description | +|-------|-------------| +| **Enable toggle** | Activates or deactivates this agent. Disabled agents receive no messages even if a URL is saved. | +| **Webhook URL** | The endpoint Sencho will POST to when an alert fires | + +Click **Save** to persist changes. Click **Test** to send a test payload immediately and verify delivery. + +At least one agent must be enabled for stack alerts to deliver notifications. See [Alerts & Notifications](/features/alerts-notifications) for how to create alert rules. + +--- + +## Appearance + +**Scope:** Global (stored in the browser, not the server) + +| Setting | Options | Description | +|---------|---------|-------------| +| **Theme** | Light / Dark / Auto | `Auto` follows the OS system preference. Changes apply immediately without a page reload. | + +--- + +## Developer + +**Scope:** Per-node (applies to the currently selected node) + +Advanced settings for log streaming behaviour and data retention. Most users can leave these at their defaults. + +| Setting | Default | Description | +|---------|---------|-------------| +| **Developer Mode** | Off | When on, the [Global Observability](/features/global-observability) view switches from polling to real-time Server-Sent Events streaming | +| **Global Logs Refresh Rate** | 5s | Polling interval in standard mode. Options: `1s`, `3s`, `5s`, `10s` | +| **Metrics Retention Hours** | 24 | How many hours of CPU/RAM history to keep for dashboard charts. Max: 8,760 (1 year) | +| **Log Retention Days** | 30 | How many days of notification history to keep in the database. Max: 365 | + +Click **Save** to apply. + + + Lower refresh rates (1s) increase backend CPU usage as Sencho polls Docker more frequently. Use only when actively debugging. + + +--- + +## Nodes + +**Scope:** Global + +Manage connections to local and remote Sencho instances. This is the same interface as the [Multi-Node](/features/multi-node) feature — see that page for the full walkthrough. + +Quick reference: + +| Action | How | +|--------|-----| +| Add a remote node | Click **+ Add Node** | +| Generate a token for this instance | Click **Generate Token** | +| Test an existing node's connectivity | Click the wifi icon on any row | +| Edit a node | Click the pencil icon | +| Delete a node | Click the trash icon (remote nodes only) | + +--- + +## App Store + +**Scope:** Global + +| Setting | Default | Description | +|---------|---------|-------------| +| **Template Registry URL** | LinuxServer.io registry | The URL Sencho fetches templates from. Must return a JSON array in Portainer v2 template format. Leave blank to restore the default. | + +Click **Save** to update the URL. Click **Refresh Cache** to clear the cached template list and fetch fresh data from the registry immediately. + +See [App Store](/features/app-store#custom-template-registry) for more on custom registries. diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..b2863720 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,58 @@ +/** + * Authentication E2E tests. + * Tests login, logout, and unauthenticated redirect. + */ +import { test, expect } from '@playwright/test'; +import { loginAs, isDashboard, TEST_USERNAME, TEST_PASSWORD } from './helpers'; + +test.describe('Authentication', () => { + test('login with valid credentials shows the dashboard', async ({ page }) => { + await loginAs(page, TEST_USERNAME, TEST_PASSWORD); + expect(await isDashboard(page)).toBe(true); + // URL should not be on a login page + expect(page.url()).not.toMatch(/login/i); + }); + + test('login with wrong password shows an error', async ({ page }) => { + await page.goto('/'); + await page.waitForTimeout(500); + + // Skip if already logged in + if (await isDashboard(page)) { + await page.context().clearCookies(); + await page.reload(); + await page.waitForTimeout(500); + } + + await page.locator('#username').fill(TEST_USERNAME); + await page.locator('#password').fill('definitely-wrong-password-xyz'); + await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click(); + + // Should show error message, not navigate to dashboard + await expect(page.locator('text=/invalid|incorrect|wrong|failed/i')).toBeVisible({ timeout: 5_000 }); + expect(await isDashboard(page)).toBe(false); + }); + + test('visiting the app without auth redirects to login', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/'); + await page.waitForTimeout(1_000); + + // Should be on login or setup, not dashboard + expect(await isDashboard(page)).toBe(false); + // Login button or setup form should be visible + const loginOrSetup = await page.locator( + 'button:has-text("Login"), button:has-text("Sign in"), button[type="submit"]' + ).first().isVisible(); + expect(loginOrSetup).toBe(true); + }); + + test('logout returns to the login screen', async ({ page }) => { + await loginAs(page); + // The logout button renders a Lucide LogOut icon — Lucide adds a CSS class matching the icon name + const logoutBtn = page.locator('button:has(.lucide-log-out)'); + await logoutBtn.click(); + await page.waitForTimeout(1_000); + expect(await isDashboard(page)).toBe(false); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..a2fcfb4c --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,73 @@ +/** + * Shared helpers for Sencho E2E tests. + * + * CREDENTIALS: Set E2E_USERNAME and E2E_PASSWORD env vars to match + * your dev instance's admin account. Defaults assume the initial setup + * was completed with username "admin" and password "password123". + * + * E2E_USERNAME=admin E2E_PASSWORD=mypassword npx playwright test + */ +import { Page, expect } from '@playwright/test'; + +export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin'; +export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123'; + +/** Selector for the dashboard — only present in EditorLayout, not on login/setup pages */ +const DASHBOARD_INDICATOR = 'img[alt="Sencho Logo"]'; + +/** Returns true if the current page is the first-run setup screen */ +async function isSetupPage(page: Page): Promise { + return page.locator('#confirmPassword, input[placeholder*="Confirm"]').isVisible().catch(() => false); +} + +/** Returns true if the current page is the login screen */ +async function isLoginPage(page: Page): Promise { + return page.locator('button:has-text("Login"), button:has-text("Sign in")').isVisible().catch(() => false); +} + +/** Returns true if the dashboard (EditorLayout) is loaded */ +export async function isDashboard(page: Page): Promise { + return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false); +} + +/** + * Navigate to the app root, complete first-run setup if needed, then log in. + * After this call the dashboard is guaranteed to be visible. + */ +export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) { + await page.goto('/'); + + // Wait for the app to finish its auth check (loading spinner disappears) + await page.waitForTimeout(500); + + // ── First-run setup ─────────────────────────────────────────────────────── + if (await isSetupPage(page)) { + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + const confirmInput = page.locator('#confirmPassword'); + if (await confirmInput.isVisible()) await confirmInput.fill(password); + await page.locator('button[type="submit"]').click(); + // After setup, the app logs in automatically and shows the dashboard + await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 }); + return; + } + + // ── Login screen ───────────────────────────────────────────────────────── + if (await isLoginPage(page)) { + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click(); + await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 }); + return; + } + + // ── Already on the dashboard ────────────────────────────────────────────── + if (await isDashboard(page)) { + return; + } + + throw new Error( + 'loginAs: could not determine page state — expected setup, login, or dashboard. ' + + 'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.', + ); +} diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts new file mode 100644 index 00000000..a2251501 --- /dev/null +++ b/e2e/nodes.spec.ts @@ -0,0 +1,62 @@ +/** + * Node management E2E tests. + * Tests the SSRF validation we added (C2 fix) is surfaced in the UI. + */ +import { test, expect } from '@playwright/test'; +import { loginAs } from './helpers'; + +test.describe('Node management', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + // Open Settings modal then navigate to the Nodes section + await page.getByRole('button', { name: /settings/i }).click(); + await page.getByRole('button', { name: /^nodes$/i }).click(); + }); + + /** + * Open the Add Node dialog and switch the type to Remote so the API URL + * field becomes visible. Returns false (and skips) if the button isn't found. + */ + async function openAddNodeAsRemote(page: import('@playwright/test').Page): Promise { + const addBtn = page.getByRole('button', { name: /add node/i }).first(); + if (!await addBtn.isVisible()) { + test.skip(); + return false; + } + await addBtn.click(); + // Wait for the dialog form to be ready + await expect(page.locator('#node-name')).toBeVisible({ timeout: 5_000 }); + // The API URL field only renders when type === 'remote'. + // #node-type is a Radix UI combobox — click to open, then pick the option. + await page.locator('#node-type').click(); + await page.getByRole('option', { name: /remote/i }).click(); + // Confirm the API URL field is now visible before proceeding + await expect(page.locator('#node-api-url')).toBeVisible({ timeout: 3_000 }); + return true; + } + + test('adding a node with localhost api_url shows a validation error', async ({ page }) => { + if (!await openAddNodeAsRemote(page)) return; + + await page.locator('#node-name').fill('bad-node'); + await page.locator('#node-api-url').fill('http://localhost:6379'); + // api_token is required to enable the submit button; use a dummy value since we're testing URL validation + await page.locator('#node-api-token').fill('dummy-token'); + // Use .last() to target the dialog submit button, not the trigger + await page.getByRole('button', { name: /add node/i }).last().click(); + + await expect(page.getByText(/loopback|localhost/i)).toBeVisible({ timeout: 5_000 }); + }); + + test('adding a node with an invalid URL shows an error', async ({ page }) => { + if (!await openAddNodeAsRemote(page)) return; + + await page.locator('#node-name').fill('bad-url-node'); + await page.locator('#node-api-url').fill('not-a-url-at-all'); + // api_token is required to enable the submit button; use a dummy value since we're testing URL validation + await page.locator('#node-api-token').fill('dummy-token'); + await page.getByRole('button', { name: /add node/i }).last().click(); + + await expect(page.getByText(/valid url|invalid url/i)).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 00000000..de54dcaf --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,52 @@ +/** + * Docs screenshot capture. + * + * Takes canonical screenshots of key UI views and writes them to docs/images/. + * Run via: npx playwright test e2e/screenshots.spec.ts + * + * The CI `update-screenshots` job runs this on every push to develop and + * commits any changed images back to the repo so sync-docs picks them up. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { test } from '@playwright/test'; +import { loginAs } from './helpers'; + +const DOCS_IMAGES = path.resolve(__dirname, '../docs/images'); + +test.use({ + viewport: { width: 1280, height: 800 }, + // Always capture — this spec exists solely to produce screenshots + screenshot: 'on', +}); + +test.beforeAll(() => { + fs.mkdirSync(DOCS_IMAGES, { recursive: true }); +}); + +test('login page', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/'); + await page.waitForTimeout(600); + await page.screenshot({ path: path.join(DOCS_IMAGES, 'login.png'), fullPage: true }); +}); + +test('dashboard', async ({ page }) => { + await loginAs(page); + // Wait for stats widgets to settle + await page.waitForTimeout(1_000); + await page.screenshot({ path: path.join(DOCS_IMAGES, 'dashboard.png'), fullPage: true }); +}); + +test('stacks', async ({ page }) => { + await loginAs(page); + await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 10_000 }); + await page.screenshot({ path: path.join(DOCS_IMAGES, 'stacks.png'), fullPage: true }); +}); + +test('resources', async ({ page }) => { + await loginAs(page); + await page.getByRole('button', { name: /resources/i }).click(); + await page.waitForTimeout(800); + await page.screenshot({ path: path.join(DOCS_IMAGES, 'resources.png'), fullPage: true }); +}); diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts new file mode 100644 index 00000000..c9a55fc6 --- /dev/null +++ b/e2e/stacks.spec.ts @@ -0,0 +1,79 @@ +/** + * Stack management E2E tests — happy path CRUD. + */ +import { test, expect } from '@playwright/test'; +import { loginAs } from './helpers'; + +const TEST_STACK = 'e2e-test-stack'; + +/** Wait for the stacks sidebar to be ready (Create Stack button is always rendered). */ +async function waitForStacksLoaded(page: import('@playwright/test').Page) { + await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); +} + +/** Delete the test stack via the browser's authenticated fetch (so cookies are included). */ +async function deleteTestStackViaApi(page: import('@playwright/test').Page) { + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, TEST_STACK); +} + +test.describe('Stack management', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await waitForStacksLoaded(page); + }); + + test('create a new stack', async ({ page }) => { + // Remove leftover from prior runs (using browser context auth) + await deleteTestStackViaApi(page); + await page.waitForTimeout(500); + + // Reload to get a fresh sidebar without the deleted stack + await page.reload(); + await loginAs(page); // may re-login if cookie expired, otherwise skips to dashboard + await waitForStacksLoaded(page); + + await page.getByRole('button', { name: 'Create Stack' }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 }); + + await page.locator('#create-stack-name').fill(TEST_STACK); + await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click(); + + // Wait for dialog to close (success) or error message to appear (failure) + await Promise.race([ + page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 8_000 }), + page.getByText(/already exists/i).waitFor({ state: 'visible', timeout: 8_000 }), + ]).catch(() => {}); + + // The stack should now exist — refresh and verify via the sidebar + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); + + await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 }); + }); + + test('delete the test stack', async ({ page }) => { + // Confirm the stack exists in the sidebar + await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 }); + + // Click on the stack to open the editor + await page.getByText(TEST_STACK).first().click(); + + // The toolbar Delete button has the Lucide Trash2 icon + const deleteBtn = page.locator('button:has(.lucide-trash-2)'); + await expect(deleteBtn).toBeVisible({ timeout: 10_000 }); + await deleteBtn.click(); + + // AlertDialog confirmation + await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 }); + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click(); + + // Stack should no longer appear in the sidebar (exact match to avoid false positives from + // similarly-named stacks; scoped to the CommandList) + await expect( + page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true }) + ).not.toBeVisible({ timeout: 8_000 }); + }); +}); diff --git a/frontend/components.json b/frontend/components.json index abd3aa3e..6770ae83 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -19,5 +19,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json" + } } diff --git a/frontend/index.html b/frontend/index.html index 6296104f..42884c56 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,9 @@ Sencho + + +