From ce50db0fdee160e20b658f98b5d8fee86215afc3 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 21 Mar 2026 21:59:44 -0400 Subject: [PATCH] security: pre-release hardening, automated testing, and production readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY (critical fixes): - Add authMiddleware to /api/system/console-token (was publicly accessible) - Validate api_url on node create/update to prevent SSRF (rejects localhost/loopback) - Add rate limiting (5 req/15 min/IP) to /api/auth/login and /api/auth/setup - Fix path traversal in env_file resolution — absolute/escaping paths rejected - Add stack name validation to GET routes (was only on PUT/POST) - Add helmet security headers middleware - Restrict CORS to FRONTEND_URL in production PRODUCTION READINESS: - Add GET /api/health public endpoint + HEALTHCHECK in Dockerfile - Add SIGTERM/SIGINT graceful shutdown handler (drains connections, closes DB) - Run container as non-root sencho user in Dockerfile QUALITY: - Fix 4 silent empty catch{} blocks in EditorLayout (now show toast.error) - Connect ErrorBoundary to root App in main.tsx - Replace WebSocket.Server with named WebSocketServer import (ESM compat) TESTING (new automated test suite): - Install Vitest; 38 backend tests across 4 suites covering validation utilities, health endpoint, auth middleware, login flows, SSRF protection, and path traversal - Extract isValidStackName/isValidRemoteUrl/isPathWithinBase to utils/validation.ts - Playwright E2E scaffolding: auth, stacks, nodes specs + shared login helper - CI: run Vitest + ESLint on every PR --- .github/workflows/ci.yml | 8 + CHANGELOG.md | 24 + Dockerfile | 13 + backend/package-lock.json | 1453 +++++++++++++++++- backend/package.json | 9 +- backend/src/__tests__/auth.test.ts | 112 ++ backend/src/__tests__/health.test.ts | 41 + backend/src/__tests__/helpers/setupTestDb.ts | 46 + backend/src/__tests__/nodes.test.ts | 96 ++ backend/src/__tests__/validation.test.ts | 100 ++ backend/src/index.ts | 131 +- backend/src/utils/validation.ts | 52 + backend/vitest.config.ts | 16 + e2e/auth.spec.ts | 49 + e2e/helpers.ts | 36 + e2e/nodes.spec.ts | 54 + e2e/stacks.spec.ts | 71 + frontend/src/components/EditorLayout.tsx | 19 +- frontend/src/main.tsx | 5 +- package-lock.json | 79 + package.json | 26 + playwright.config.ts | 35 + 22 files changed, 2445 insertions(+), 30 deletions(-) create mode 100644 backend/src/__tests__/auth.test.ts create mode 100644 backend/src/__tests__/health.test.ts create mode 100644 backend/src/__tests__/helpers/setupTestDb.ts create mode 100644 backend/src/__tests__/nodes.test.ts create mode 100644 backend/src/__tests__/validation.test.ts create mode 100644 backend/src/utils/validation.ts create mode 100644 backend/vitest.config.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/helpers.ts create mode 100644 e2e/nodes.spec.ts create mode 100644 e2e/stacks.spec.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f8e3a8..c7dd343e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,11 @@ jobs: - name: Build Frontend (Vite/React) working-directory: ./frontend run: npm run build + + - name: Run Backend Unit Tests (Vitest) + working-directory: ./backend + run: npm test + + - name: Lint Frontend (ESLint) + working-directory: ./frontend + run: npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index ab78bc62..3931d14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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] + +### Security +- **Fixed:** `POST /api/system/console-token` was missing `authMiddleware` — any unauthenticated client could generate console session tokens. Fixed by adding `authMiddleware` to the route. +- **Fixed:** Remote node `api_url` was 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/written. All resolved env file paths are now validated to stay within the stack directory. +- **Fixed:** Stack name was validated in write routes but not in GET routes (`/api/stacks/:stackName`, `/api/stacks/:stackName/env`, `/api/stacks/:stackName/envs`) — path-traversal names now return 400 on all routes. +- **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 +- **Added:** `GET /api/health` public endpoint — returns `{ status: "ok", uptime }`. Used by Docker `HEALTHCHECK` directive and external uptime monitors. +- **Added:** `HEALTHCHECK` directive in `Dockerfile` — Docker now polls `/api/health` every 30 s and restarts an unhealthy container. +- **Added:** Graceful shutdown — backend now listens for `SIGTERM`/`SIGINT`, drains HTTP connections, stops `MonitorService` and `ImageUpdateService`, and closes the SQLite connection before exiting. Prevents data loss when the container is stopped. +- **Added:** Automated backend test suite with Vitest — 38 tests covering validation utilities, health endpoint, authentication flows, auth middleware enforcement, console-token security fix, and SSRF validation on node URLs. Run with `cd backend && npm test`. +- **Added:** Playwright E2E test scaffolding (`e2e/`) — auth, stack management, and node management spec files with shared login helper. Run with `npm run test:e2e` from the repo root. +- **Added:** CI workflow now runs Vitest unit tests and ESLint on every PR. +- **Added:** Dockerfile now creates a non-root `sencho` system user and runs the process as that user instead of root. +- **Added:** `isValidStackName`, `isValidRemoteUrl`, `isPathWithinBase` extracted to `backend/src/utils/validation.ts` for reuse and testability. + +### Fixed +- **Fixed:** Four empty `catch {}` blocks in `EditorLayout` (mark-all-read, delete notification, clear-all notifications, image update fetch) now surface errors via `toast.error()` instead of silently swallowing them. +- **Fixed:** `ErrorBoundary` component existed but was not connected — it now wraps the root `` in `main.tsx`, catching crashes in any context provider or route component. +- **Fixed:** `WebSocket.Server` replaced with named import `WebSocketServer` from `ws` to fix ESM/CJS interop in test environments. - **Added:** Cross-node notification aggregation — the notification bell now surfaces alerts from all connected remote nodes, not just the local instance. On mount and whenever the node list changes, `EditorLayout` fetches notification history from every registered node in parallel (using `fetchForNode` with targeted `x-node-id` headers). Each remote node also gets a dedicated real-time WebSocket connection (`/ws/notifications?nodeId=`) so alerts push instantly as they fire. Remote-sourced notifications display a node-name badge for quick identification. Mark-as-read, delete, and clear-all actions are routed to the correct node. The backend WS upgrade handler was updated to allow `/ws/notifications?nodeId=` to fall through to the existing proxy path (bare `/ws/notifications` with no nodeId continues to connect locally as before). - **Fixed:** Remote node host console and container exec WebSocket connections now succeed — the gateway exchanges the long-lived `node_proxy` api_token for a short-lived `console_session` JWT (60 s TTL) via a new `POST /api/system/console-token` endpoint before forwarding the WS upgrade to the remote. Previously the remote's `isProxyToken` guard correctly blocked `node_proxy` tokens from interactive terminals, which also blocked legitimate user-initiated console sessions routed through the gateway. - **Fixed:** `StackAlertSheet` now fetches notification agent status from the active node on open and displays a contextual banner: green checkmark with active channel names when agents are enabled, amber warning with a link to Settings → Notifications when none are configured, and a blue info callout on remote nodes explaining that alerts are evaluated and dispatched by that remote Sencho instance. diff --git a/Dockerfile b/Dockerfile index 25538c6f..27e5d41b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,8 +58,21 @@ 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 + +USER sencho + # Expose port EXPOSE 3000 +# 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))" + # Start the server CMD ["node", "dist/index.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 5b9e87f2..ede1a385 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,8 @@ "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", @@ -38,10 +40,13 @@ "@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" } }, "node_modules/@balena/dockerignore": { @@ -63,6 +68,40 @@ "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/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -150,6 +189,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", @@ -214,6 +303,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", @@ -242,6 +600,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", @@ -272,6 +641,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", @@ -291,6 +671,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", @@ -300,6 +687,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", @@ -321,6 +715,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", @@ -383,6 +784,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", @@ -455,6 +863,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", @@ -474,6 +906,119 @@ "yaml": "*" } }, + "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", @@ -584,6 +1129,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -593,6 +1145,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", @@ -845,6 +1407,16 @@ "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/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -920,6 +1492,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", @@ -980,6 +1562,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", @@ -1017,6 +1606,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", @@ -1149,6 +1745,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", @@ -1263,6 +1870,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", @@ -1305,6 +1919,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "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/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1329,11 +1953,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", @@ -1372,12 +2007,37 @@ "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-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", @@ -1490,6 +2150,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", @@ -1664,6 +2342,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", @@ -1779,6 +2466,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", @@ -1910,6 +2606,267 @@ "safe-buffer": "^5.0.1" } }, + "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/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -1976,6 +2933,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", @@ -2013,6 +2980,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", @@ -2026,6 +3003,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", @@ -2107,6 +3097,25 @@ "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", @@ -2230,6 +3239,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", @@ -2270,6 +3290,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", @@ -2282,6 +3316,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", @@ -2474,6 +3537,40 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "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", @@ -2651,6 +3748,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", @@ -2709,6 +3813,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", @@ -2732,6 +3846,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", @@ -2741,6 +3862,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", @@ -2785,6 +3913,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", @@ -2852,6 +4016,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", @@ -2927,6 +4167,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", @@ -3031,6 +4279,209 @@ "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", + "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/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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 360c3c52..6fe739ad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "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" }, "keywords": [], "author": "", @@ -20,10 +20,13 @@ "@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" }, "dependencies": { "@types/cors": "^2.8.19", @@ -39,6 +42,8 @@ "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", 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 ad887ca4..f7e01871 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'; @@ -25,6 +27,7 @@ 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'; @@ -64,8 +67,20 @@ const getCookieOptions = (req: Request) => ({ }); // Middleware + +// Security headers (X-Frame-Options, X-Content-Type-Options, etc.) +// crossOriginEmbedderPolicy is disabled because the Monaco editor uses workers +// that don't set the required COEP headers. +app.use(helmet({ crossOriginEmbedderPolicy: false })); + +// 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, })); // Conditionally parse JSON bodies. Remote proxy requests must NOT have their body @@ -177,6 +192,22 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): } }; +// Rate limiter for auth endpoints — prevents brute-force attacks. +// 5 attempts per 15-minute window per IP. Applies to login and setup only; +// password-change is already protected by authMiddleware + old-password verification. +const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + 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 @@ -192,7 +223,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(); @@ -243,7 +274,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) { @@ -443,7 +474,7 @@ 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; @@ -504,7 +535,7 @@ server.on('upgrade', async (req, socket, head) => { // 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 WebSocket.Server({ noServer: true }); + const notifWss = new WebSocketServer({ noServer: true }); notifWss.handleUpgrade(req, socket, head, (ws) => { notifWss.close(); notificationSubscribers.add(ws); @@ -568,7 +599,7 @@ 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 @@ -591,7 +622,7 @@ server.on('upgrade', async (req, socket, head) => { socket.destroy(); return; } - const hostConsoleWss = new WebSocket.Server({ noServer: true }); + const hostConsoleWss = new WebSocketServer({ noServer: true }); hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { hostConsoleWss.close(); let targetDirectory = ''; @@ -708,6 +739,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) { @@ -768,20 +802,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); } } } @@ -801,6 +837,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) { @@ -811,6 +850,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); @@ -1539,7 +1581,7 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => { // 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', (req: Request, res: Response): void => { +app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; @@ -1801,6 +1843,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 { @@ -1836,8 +1879,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({ @@ -1865,6 +1914,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 @@ -1948,4 +2004,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/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/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..f4c29e4c --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,49 @@ +/** + * Authentication E2E tests. + * Tests login, logout, and unauthenticated redirect. + */ +import { test, expect } from '@playwright/test'; +import { loginAs, TEST_USERNAME, TEST_PASSWORD } from './helpers'; + +test.describe('Authentication', () => { + test('login with valid credentials shows the dashboard', async ({ page }) => { + await loginAs(page); + // Should see the main editor/dashboard — not a login page + await expect(page).not.toHaveURL(/login/i); + await expect(page.getByRole('main')).toBeVisible(); + }); + + test('login with wrong password shows an error', async ({ page }) => { + await page.goto('/'); + // Skip setup if needed + const isSetup = await page.getByRole('heading', { name: /setup/i }).isVisible().catch(() => false); + if (isSetup) { + // Must complete setup before we can test wrong password + await loginAs(page); + await page.goto('/login'); + } + + await page.getByLabel(/username/i).fill(TEST_USERNAME); + await page.getByLabel(/password/i).fill('definitly-wrong-password'); + await page.getByRole('button', { name: /login|sign in/i }).click(); + + await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible(); + }); + + test('visiting a protected page without auth redirects to login', async ({ page }) => { + // Clear cookies to simulate logged-out state + await page.context().clearCookies(); + await page.goto('/'); + await expect(page).toHaveURL(/login|setup/i); + }); + + test('logout redirects to login', async ({ page }) => { + await loginAs(page); + // Find and click the logout button (varies by UI — adjust selector as needed) + const logoutBtn = page.getByRole('button', { name: /logout|sign out/i }); + if (await logoutBtn.isVisible()) { + await logoutBtn.click(); + await expect(page).toHaveURL(/login/i); + } + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..fb7187ed --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,36 @@ +/** + * Shared helpers for E2E tests. + * + * The dev backend must be running at localhost:3000 and seeded via the setup flow, + * OR use a fixed set of test credentials. + */ +import { Page, expect } from '@playwright/test'; + +export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin'; +export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123'; + +/** Navigate to app, complete setup if needed, then log in. */ +export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) { + await page.goto('/'); + + // If setup page is shown, complete it first + const isSetup = await page.getByRole('heading', { name: /setup/i }).isVisible().catch(() => false); + if (isSetup) { + await page.getByLabel(/username/i).fill(username); + await page.getByLabel(/^password$/i).fill(password); + const confirmInput = page.getByLabel(/confirm password/i); + if (await confirmInput.isVisible()) await confirmInput.fill(password); + await page.getByRole('button', { name: /create account|setup|submit/i }).click(); + await page.waitForURL(/login|dashboard|\//); + } + + // Login if redirected to login page + const isLogin = await page.getByRole('heading', { name: /login|sign in/i }).isVisible().catch(() => false); + if (isLogin) { + await page.getByLabel(/username/i).fill(username); + await page.getByLabel(/password/i).fill(password); + await page.getByRole('button', { name: /login|sign in/i }).click(); + // Wait for the dashboard to load + await expect(page.getByRole('main')).toBeVisible({ timeout: 10_000 }); + } +} diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts new file mode 100644 index 00000000..39104e38 --- /dev/null +++ b/e2e/nodes.spec.ts @@ -0,0 +1,54 @@ +/** + * 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); + // Navigate to the nodes section (Settings / Node Manager) + const nodesBtn = page.getByRole('button', { name: /nodes|manage nodes|settings/i }).first(); + if (await nodesBtn.isVisible()) await nodesBtn.click(); + }); + + test('adding a node with localhost api_url shows a validation error', async ({ page }) => { + // Open "add node" dialog + const addBtn = page.getByRole('button', { name: /add node|new node|\+/i }); + if (!await addBtn.isVisible()) { + test.skip(); + return; + } + await addBtn.click(); + + await page.getByLabel(/node name/i).fill('bad-node'); + // Select "remote" type if there's a type selector + const typeSelect = page.getByLabel(/type/i); + if (await typeSelect.isVisible()) await typeSelect.selectOption('remote'); + + await page.getByLabel(/api url/i).fill('http://localhost:6379'); + await page.getByRole('button', { name: /add|save|create/i }).click(); + + // Should see an error about loopback/localhost + await expect(page.getByText(/loopback|localhost/i)).toBeVisible({ timeout: 3_000 }); + }); + + test('adding a node with an invalid URL shows an error', async ({ page }) => { + const addBtn = page.getByRole('button', { name: /add node|new node|\+/i }); + if (!await addBtn.isVisible()) { + test.skip(); + return; + } + await addBtn.click(); + + await page.getByLabel(/node name/i).fill('bad-url-node'); + const typeSelect = page.getByLabel(/type/i); + if (await typeSelect.isVisible()) await typeSelect.selectOption('remote'); + + await page.getByLabel(/api url/i).fill('not-a-url-at-all'); + await page.getByRole('button', { name: /add|save|create/i }).click(); + + await expect(page.getByText(/valid url|invalid url|url/i)).toBeVisible({ timeout: 3_000 }); + }); +}); diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts new file mode 100644 index 00000000..da17b128 --- /dev/null +++ b/e2e/stacks.spec.ts @@ -0,0 +1,71 @@ +/** + * Stack management E2E tests — happy path CRUD. + * + * NOTE: These tests require Docker Compose to be installed on the host, because + * actual stack operations (up/down) spawn docker-compose processes. + * The create/edit/delete tests work without Docker being connected. + */ +import { test, expect } from '@playwright/test'; +import { loginAs } from './helpers'; + +const TEST_STACK = `e2e-test-stack-${Date.now()}`; +const SIMPLE_COMPOSE = `services:\n web:\n image: nginx:alpine\n`; + +test.describe('Stack management', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + }); + + test('create a new stack', async ({ page }) => { + // Find and click the "new stack" / "+" button + const newStackBtn = page.getByRole('button', { name: /new stack|create stack|\+/i }).first(); + await newStackBtn.click(); + + // Fill in the stack name in the dialog + const nameInput = page.getByLabel(/stack name/i); + await nameInput.fill(TEST_STACK); + + // Confirm + await page.getByRole('button', { name: /create|confirm|ok/i }).click(); + + // Stack should now appear in the list + await expect(page.getByText(TEST_STACK)).toBeVisible({ timeout: 5_000 }); + }); + + test('edit the compose file of an existing stack', async ({ page }) => { + // Click on the test stack in the sidebar/list + await page.getByText(TEST_STACK).click(); + + // Wait for the editor to appear and type some content + const editor = page.locator('.monaco-editor').first(); + await editor.click(); + await page.keyboard.selectAll(); + await page.keyboard.type(SIMPLE_COMPOSE); + + // Save + const saveBtn = page.getByRole('button', { name: /save/i }); + await saveBtn.click(); + + // Should show success indication (no error toast) + await expect(page.getByText(/error/i)).not.toBeVisible({ timeout: 3_000 }).catch(() => { + // If error text is already not there that's fine + }); + }); + + test('delete the test stack', async ({ page }) => { + // Find the test stack and open its context menu / delete button + const stackRow = page.locator(`[data-testid="stack-${TEST_STACK}"], li:has-text("${TEST_STACK}")`).first(); + + // Hover to reveal action buttons + await stackRow.hover(); + const deleteBtn = stackRow.getByRole('button', { name: /delete|remove/i }); + await deleteBtn.click(); + + // Confirm deletion in dialog + const confirmBtn = page.getByRole('button', { name: /confirm|delete|yes/i }); + if (await confirmBtn.isVisible()) await confirmBtn.click(); + + // Stack should no longer appear + await expect(page.getByText(TEST_STACK)).not.toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index f9ef2149..40406563 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -410,7 +410,9 @@ export default function EditorLayout() { const data = await res.json(); setStackUpdates(data); } - } catch (e) { } + } catch (e: unknown) { + console.error('[ImageUpdates] fetch failed:', e); + } }; const markAllRead = async () => { @@ -423,7 +425,10 @@ export default function EditorLayout() { : fetchForNode('/notifications/read', nodeId, { method: 'POST' }) )); setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 }))); - } catch (e) { } + } catch (e: unknown) { + const err = e as { message?: string; error?: string }; + toast.error(err?.message || err?.error || 'Failed to mark notifications as read'); + } }; const deleteNotification = async (notif: Notification) => { @@ -435,7 +440,10 @@ export default function EditorLayout() { await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' }); } setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId))); - } catch (e) { } + } catch (e: unknown) { + const err = e as { message?: string; error?: string }; + toast.error(err?.message || err?.error || 'Failed to delete notification'); + } }; const clearAllNotifications = async () => { @@ -448,7 +456,10 @@ export default function EditorLayout() { : fetchForNode('/notifications', nodeId, { method: 'DELETE' }) )); setNotifications([]); - } catch (e) { } + } catch (e: unknown) { + const err = e as { message?: string; error?: string }; + toast.error(err?.message || err?.error || 'Failed to clear notifications'); + } }; useEffect(() => { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202a..a7fa8ce9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import ErrorBoundary from './components/ErrorBoundary.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..b83f7fa3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "sencho", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sencho", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..8647da7d --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "sencho", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "cd backend && npm test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/AnsoCode/Sencho.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/AnsoCode/Sencho/issues" + }, + "homepage": "https://github.com/AnsoCode/Sencho#readme", + "devDependencies": { + "@playwright/test": "^1.58.2" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..89483c13 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E config for Sencho. + * + * Before running: ensure both dev servers are up: + * cd backend && npm run dev & + * cd frontend && npm run dev & + * + * Or use the webServer config below (which starts them automatically). + */ +export default defineConfig({ + testDir: './e2e', + // Stop on first failure to save time during development + maxFailures: 1, + // How long to wait for a single test + timeout: 30_000, + // How long to wait for an expect() assertion + expect: { timeout: 5_000 }, + // Run tests serially — Sencho is a single-user app and tests share DB state + workers: 1, + reporter: [['list'], ['html', { outputFolder: 'e2e/report', open: 'never' }]], + + use: { + baseURL: 'http://localhost:5173', + // Persist auth state between tests in the same file + storageState: undefined, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +});