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'] } },
+ ],
+});