diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f62a7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.codex +node_modules +dist +server-dist +docs +*.md +coverage +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/.gitignore b/.gitignore index 8fc24d6..b8fee09 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +server-dist dist-ssr *.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5583859 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM node:22-bookworm-slim AS app-build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm install + +COPY tsconfig.json tsconfig.app.json tsconfig.node.json tsconfig.server.json vite.config.ts index.html ./ +COPY public ./public +COPY src ./src +COPY server ./server + +RUN npm run build + +FROM debian:bookworm-slim AS proxy-build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp +RUN git clone --depth=1 https://github.com/3proxy/3proxy.git + +WORKDIR /tmp/3proxy +RUN ln -s Makefile.Linux Makefile && make + +FROM node:22-bookworm-slim + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV AUTO_START_3PROXY=true +ENV THREEPROXY_BINARY=/usr/local/bin/3proxy +ENV RUNTIME_DIR=/app/runtime + +COPY --from=app-build /app/dist ./dist +COPY --from=app-build /app/server-dist ./server-dist +COPY --from=proxy-build /tmp/3proxy/bin/3proxy /usr/local/bin/3proxy + +RUN mkdir -p /app/runtime/generated /app/runtime/state/reports /app/runtime/logs + +EXPOSE 3000 1080 2080 3128 8081 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +CMD ["node", "server-dist/index.cjs"] diff --git a/README.md b/README.md index d4967ca..4718808 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,49 @@ Control panel and runtime bundle for 3proxy in Docker. ## Current focus -The first delivered slice is the UI foundation: +The project now includes both the UI and the first backend/runtime slice: -- login screen with hardcoded panel credentials -- dashboard, users, and system views -- domain utilities and tests for quota, status, and proxy-link behavior -- project workflow docs for autonomous delivery +- Express-based control plane API +- generated `3proxy.cfg` from persisted panel state +- runtime manager for start/restart/reload +- Docker image that builds the panel and compiles 3proxy in-container +- panel views for dashboard, users, and system +- edge-case-focused frontend and backend tests ## Local run ```bash npm install npm run dev +npm run dev:server ``` -Default panel credentials for the current UI prototype: +Default panel credentials: - login: `admin` - password: `proxy-ui-demo` +## Docker run + +```bash +docker compose up --build +``` + +Published ports: + +- panel: `3000` +- socks main: `1080` +- socks lab: `2080` +- http proxy: `3128` +- 3proxy admin: `8081` + +Runtime state is persisted in the Docker volume `3proxyui_3proxy-runtime`. + ## Scripts ```bash npm run dev +npm run dev:server npm run build npm run test npm run test:run diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a1e8cc3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,23 @@ +services: + 3proxy-ui: + build: + context: . + dockerfile: Dockerfile + container_name: 3proxy-ui + ports: + - "3000:3000" + - "1080:1080" + - "2080:2080" + - "3128:3128" + - "8081:8081" + environment: + PORT: "3000" + AUTO_START_3PROXY: "true" + THREEPROXY_BINARY: "/usr/local/bin/3proxy" + RUNTIME_DIR: "/app/runtime" + volumes: + - 3proxy-runtime:/app/runtime + restart: unless-stopped + +volumes: + 3proxy-runtime: diff --git a/docs/PLAN.md b/docs/PLAN.md index 808b50a..efdba67 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -4,13 +4,13 @@ Updated: 2026-04-01 ## Active -1. Present the UI-first slice for approval, then replace mocks with runtime-backed 3proxy control flows. +1. Harden the new backend/runtime layer, expand system configuration flows, and keep wiring the UI to real panel state instead of fallbacks. ## Next -1. Replace mocks with a backend control plane for config generation, process management, counters, and health checks. -2. Add Docker runtime with 3proxy, panel server, health checks, and reload/start/restart operations. -3. Extend tests to cover config rendering, unsafe input handling, and runtime failure scenarios. +1. Extend the backend to support system-tab editing for services, ports, and runtime configuration. +2. Add stronger validation and tests for unsafe credentials, conflicting ports, and invalid service assignment. +3. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints. ## Done @@ -25,3 +25,6 @@ Updated: 2026-04-01 9. Stabilized the Users table copy action so the column no longer shifts when the button label changes to `Copied`. 10. Added operator actions in the Users table for pause/resume and delete with confirmation modal coverage. 11. Added a root quick-start prompt file so a new agent can resume implementation or fixes with minimal onboarding. +12. Added a backend control plane with persisted state, 3proxy config generation, runtime actions, and API-backed frontend wiring. +13. Added Docker build/compose runtime that compiles 3proxy in-container and starts the panel with a managed 3proxy process. +14. Added backend tests for config rendering and user-management API edge cases. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 31c9a8a..1797f27 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -5,9 +5,13 @@ Updated: 2026-04-01 ## Root - `000_START_HERE.md`: copy-ready continuation prompt for the next agent session +- `.dockerignore`: trims Docker build context to runtime-relevant files only - `AGENTS.md`: repository workflow rules for autonomous contributors +- `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime +- `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy - `README.md`: quick start and current project scope - `package.json`: frontend scripts and dependencies +- `tsconfig.server.json`: server type-check configuration - `vite.config.ts`: Vite + Vitest configuration ## Documentation @@ -19,14 +23,25 @@ Updated: 2026-04-01 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell rebuilt around a topbar/tab layout, plus modal user creation, pause/resume, and delete-confirm flows +- `src/App.tsx`: authenticated panel shell wired to backend APIs with local fallback behavior - `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests - `src/app.css`: full panel styling -- `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses, including service-bound users +- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules +- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts - `src/test/setup.ts`: Testing Library matchers +## Server + +- `server/index.ts`: backend entrypoint and runtime bootstrap +- `server/app.ts`: Express app with panel state and runtime routes +- `server/app.test.ts`: API tests for user management edge cases +- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation +- `server/lib/config.test.ts`: config-generation regression tests +- `server/lib/runtime.ts`: managed 3proxy process controller +- `server/lib/store.ts`: JSON-backed persistent state store + ## Static - `public/favicon.svg`: Vite default icon placeholder, to replace later diff --git a/package-lock.json b/package-lock.json index e34ffbc..9b3200f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "3proxyui", "version": "0.0.0", "dependencies": { + "express": "^5.2.1", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -15,10 +16,15 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/express": "^5.0.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", + "esbuild": "^0.27.4", "jsdom": "^29.0.1", + "supertest": "^7.2.2", + "tsx": "^4.21.0", "typescript": "~5.9.3", "vite": "^8.0.1", "vitest": "^4.1.2" @@ -262,6 +268,448 @@ "node": ">=20.19.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -306,6 +754,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "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.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -316,6 +777,16 @@ "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/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -694,6 +1165,17 @@ "license": "MIT", "peer": true }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -705,6 +1187,23 @@ "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", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -719,6 +1218,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "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/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -739,6 +1301,51 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "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/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -885,6 +1492,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -920,6 +1540,13 @@ "dequal": "^2.0.3" } }, + "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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -930,6 +1557,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -940,6 +1574,68 @@ "require-from-string": "^2.0.2" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -950,6 +1646,51 @@ "node": ">=18" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "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/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "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", @@ -957,6 +1698,31 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "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/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -999,6 +1765,23 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1006,6 +1789,25 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1026,6 +1828,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/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -1034,6 +1847,35 @@ "license": "MIT", "peer": true }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1047,6 +1889,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "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", @@ -1054,6 +1914,82 @@ "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", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1064,6 +2000,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1074,6 +2019,56 @@ "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", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1092,6 +2087,103 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "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", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1107,6 +2199,117 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1120,6 +2323,42 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -1130,6 +2369,21 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1137,6 +2391,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1478,6 +2738,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -1485,6 +2754,75 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.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", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -1495,6 +2833,12 @@ "node": ">=4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1514,6 +2858,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1525,6 +2890,27 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -1538,6 +2924,25 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", + "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1610,6 +3015,19 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1620,6 +3038,45 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1673,6 +3130,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -1707,6 +3174,28 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -1726,6 +3215,129 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1750,6 +3362,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -1770,6 +3391,42 @@ "node": ">=8" } }, + "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/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -1841,6 +3498,15 @@ "dev": true, "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -1875,6 +3541,40 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1899,6 +3599,31 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", @@ -2124,6 +3849,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 37a94e4..4d3d05e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "dev:server": "tsx server/index.ts", + "build:client": "vite build", + "build:server": "tsc -p tsconfig.server.json --noEmit && esbuild server/index.ts --bundle --platform=node --format=cjs --outfile=server-dist/index.cjs", + "build": "npm run build:client && npm run build:server", "preview": "vite preview", "test": "vitest", "test:run": "vitest run" @@ -14,15 +17,21 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/express": "^5.0.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", + "esbuild": "^0.27.4", "jsdom": "^29.0.1", + "supertest": "^7.2.2", + "tsx": "^4.21.0", "typescript": "~5.9.3", "vite": "^8.0.1", "vitest": "^4.1.2" }, "dependencies": { + "express": "^5.2.1", "react": "^19.2.4", "react-dom": "^19.2.4" } diff --git a/server/app.test.ts b/server/app.test.ts new file mode 100644 index 0000000..a1a2125 --- /dev/null +++ b/server/app.test.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import request from 'supertest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createApp } from './app'; +import type { RuntimeSnapshot } from './lib/config'; +import type { RuntimeController } from './lib/runtime'; +import { StateStore } from './lib/store'; + +class FakeRuntime implements RuntimeController { + private status: RuntimeSnapshot = { + status: 'idle' as const, + pid: null, + startedAt: null, + lastError: null, + }; + + getSnapshot() { + return { ...this.status }; + } + + async start() { + this.status = { + status: 'live', + pid: 999, + startedAt: new Date('2026-04-01T00:00:00.000Z').toISOString(), + lastError: null, + }; + return this.getSnapshot(); + } + + async restart() { + return this.start(); + } + + async reload() { + return this.getSnapshot(); + } +} + +const cleanupDirs: string[] = []; + +afterEach(async () => { + await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('panel api', () => { + it('rejects user creation against a non-assignable service', async () => { + const app = await createTestApp(); + const response = await request(app).post('/api/users').send({ + username: 'bad-admin-user', + password: 'secret123', + serviceId: 'admin', + quotaMb: 100, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/enabled assignable/i); + }); + + it('pauses and deletes a user through the api', async () => { + const app = await createTestApp(); + const initial = await request(app).get('/api/state'); + const userId = initial.body.userRecords[0].id; + const username = initial.body.userRecords[0].username; + + const paused = await request(app).post(`/api/users/${userId}/pause`); + expect(paused.status).toBe(200); + expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true); + + const removed = await request(app).delete(`/api/users/${userId}`); + expect(removed.status).toBe(200); + expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe( + false, + ); + }); +}); + +async function createTestApp() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), '3proxy-ui-')); + cleanupDirs.push(dir); + + const runtime = new FakeRuntime(); + const store = new StateStore(path.join(dir, 'state', 'panel-state.json')); + + return createApp({ + store, + runtime, + runtimeRootDir: dir, + }); +} diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..7f559eb --- /dev/null +++ b/server/app.ts @@ -0,0 +1,179 @@ +import express, { type Request, type Response } from 'express'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { ControlPlaneState, CreateUserInput } from '../src/shared/contracts'; +import { + buildRuntimePaths, + createUserRecord, + deriveDashboardSnapshot, + render3proxyConfig, + validateCreateUserInput, + type RuntimePaths, +} from './lib/config'; +import type { RuntimeController } from './lib/runtime'; +import { StateStore } from './lib/store'; + +export interface AppServices { + store: StateStore; + runtime: RuntimeController; + runtimeRootDir: string; +} + +export function createApp({ store, runtime, runtimeRootDir }: AppServices) { + const app = express(); + const runtimePaths = buildRuntimePaths(runtimeRootDir); + const distDir = path.resolve('dist'); + + app.use(express.json()); + app.use(express.static(distDir)); + + app.get('/api/health', async (_request, response) => { + const state = await store.read(); + const previewConfig = render3proxyConfig(state, runtimePaths); + response.json({ + ok: true, + runtime: runtime.getSnapshot(), + users: state.userRecords.length, + configBytes: Buffer.byteLength(previewConfig), + }); + }); + + app.get('/api/state', async (_request, response, next) => { + try { + const payload = await getSnapshot(store, runtime, runtimePaths); + response.json(payload); + } catch (error) { + next(error); + } + }); + + app.post('/api/runtime/:action', async (request, response, next) => { + try { + const state = await store.read(); + const action = request.params.action; + const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime); + + if (!['start', 'restart'].includes(action)) { + response.status(404).json({ error: 'Unknown runtime action.' }); + return; + } + + const runtimeSnapshot = await controller(); + state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel'; + if (runtimeSnapshot.startedAt) { + state.service.startedAt = runtimeSnapshot.startedAt; + } + + await writeConfigAndState(store, state, runtimePaths); + response.json(await getSnapshot(store, runtime, runtimePaths)); + } catch (error) { + next(error); + } + }); + + app.post('/api/users', async (request, response, next) => { + try { + const state = await store.read(); + const input = validateCreateUserInput(request.body as Partial, state.system.services); + const record = createUserRecord(state, input); + state.userRecords.push(record); + state.service.lastEvent = `User ${record.username} created from panel`; + await persistRuntimeMutation(store, runtime, state, runtimePaths); + response.status(201).json(await getSnapshot(store, runtime, runtimePaths)); + } catch (error) { + next(error); + } + }); + + app.post('/api/users/:id/pause', async (request, response, next) => { + try { + const state = await store.read(); + const user = state.userRecords.find((entry) => entry.id === request.params.id); + + if (!user) { + response.status(404).json({ error: 'User not found.' }); + return; + } + + user.paused = !user.paused; + state.service.lastEvent = user.paused + ? `User ${user.username} paused from panel` + : `User ${user.username} resumed from panel`; + + await persistRuntimeMutation(store, runtime, state, runtimePaths); + response.json(await getSnapshot(store, runtime, runtimePaths)); + } catch (error) { + next(error); + } + }); + + app.delete('/api/users/:id', async (request, response, next) => { + try { + const state = await store.read(); + const index = state.userRecords.findIndex((entry) => entry.id === request.params.id); + + if (index === -1) { + response.status(404).json({ error: 'User not found.' }); + return; + } + + const [removed] = state.userRecords.splice(index, 1); + state.service.lastEvent = `User ${removed.username} deleted from panel`; + await persistRuntimeMutation(store, runtime, state, runtimePaths); + response.json(await getSnapshot(store, runtime, runtimePaths)); + } catch (error) { + next(error); + } + }); + + app.use(async (_request, response) => { + const distPath = path.join(distDir, 'index.html'); + + try { + const html = await fs.readFile(distPath, 'utf8'); + response.type('html').send(html); + } catch { + response.status(404).send('Frontend build not found.'); + } + }); + + app.use((error: unknown, _request: Request, response: Response, _next: express.NextFunction) => { + response.status(400).json({ + error: error instanceof Error ? error.message : 'Unknown server error.', + }); + }); + + return app; +} + +async function getSnapshot( + store: StateStore, + runtime: RuntimeController, + runtimePaths: RuntimePaths, +) { + const state = await store.read(); + const previewConfig = render3proxyConfig(state, runtimePaths); + return deriveDashboardSnapshot(state, runtime.getSnapshot(), previewConfig); +} +async function persistRuntimeMutation( + store: StateStore, + runtime: RuntimeController, + state: ControlPlaneState, + runtimePaths: RuntimePaths, +) { + await writeConfigAndState(store, state, runtimePaths); + await runtime.reload(); +} + +async function writeConfigAndState( + store: StateStore, + state: ControlPlaneState, + runtimePaths: RuntimePaths, +) { + const config = render3proxyConfig(state, runtimePaths); + await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true }); + await fs.mkdir(path.dirname(runtimePaths.logPath), { recursive: true }); + await fs.mkdir(runtimePaths.reportDir, { recursive: true }); + await fs.writeFile(runtimePaths.configPath, config, 'utf8'); + await store.write(state); +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..65b7fdd --- /dev/null +++ b/server/index.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { createApp } from './app'; +import { buildRuntimePaths, render3proxyConfig } from './lib/config'; +import { ThreeProxyManager } from './lib/runtime'; +import { StateStore } from './lib/store'; + +const port = Number(process.env.PORT ?? '3000'); +const runtimeRootDir = path.resolve(process.env.RUNTIME_DIR ?? 'runtime'); +const statePath = path.join(runtimeRootDir, 'state', 'panel-state.json'); +const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy'); +const autoStart = process.env.AUTO_START_3PROXY === 'true'; + +const store = new StateStore(statePath); +const runtimePaths = buildRuntimePaths(runtimeRootDir); +const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart); + +async function main() { + const initialState = await store.read(); + await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true }); + await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8'); + await runtime.initialize(); + const app = createApp({ store, runtime, runtimeRootDir }); + app.listen(port, () => { + console.log(`Panel server listening on http://0.0.0.0:${port}`); + }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/server/lib/config.test.ts b/server/lib/config.test.ts new file mode 100644 index 0000000..05753b5 --- /dev/null +++ b/server/lib/config.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultState, render3proxyConfig } from './config'; + +describe('render3proxyConfig', () => { + it('renders enabled services with their own ports and per-service ACLs', () => { + const state = createDefaultState(); + const config = render3proxyConfig(state, { + rootDir: '/runtime', + configPath: '/runtime/generated/3proxy.cfg', + counterPath: '/runtime/state/counters.3cf', + reportDir: '/runtime/state/reports', + logPath: '/runtime/logs/3proxy.log', + pidPath: '/runtime/3proxy.pid', + }); + + expect(config).toContain('socks -p1080 -u2'); + expect(config).toContain('socks -p2080 -u2'); + expect(config).toContain('admin -p8081 -s'); + expect(config).toContain('allow night-shift,ops-east'); + expect(config).toContain('allow lab-unlimited,burst-user'); + }); + + it('excludes paused users from credentials and ACLs', () => { + const state = createDefaultState(); + state.userRecords[0].paused = true; + const config = render3proxyConfig(state, { + rootDir: '/runtime', + configPath: '/runtime/generated/3proxy.cfg', + counterPath: '/runtime/state/counters.3cf', + reportDir: '/runtime/state/reports', + logPath: '/runtime/logs/3proxy.log', + pidPath: '/runtime/3proxy.pid', + }); + + expect(config).not.toContain('night-shift:CL:kettle!23'); + expect(config).not.toContain('allow night-shift,ops-east'); + expect(config).toContain('allow ops-east'); + }); +}); diff --git a/server/lib/config.ts b/server/lib/config.ts new file mode 100644 index 0000000..9c2cf66 --- /dev/null +++ b/server/lib/config.ts @@ -0,0 +1,274 @@ +import path from 'node:path'; +import { dashboardSnapshot } from '../../src/data/mockDashboard'; +import type { + ControlPlaneState, + CreateUserInput, + DashboardSnapshot, + ProxyServiceRecord, + ProxyUserRecord, +} from '../../src/shared/contracts'; +import type { ServiceState } from '../../src/lib/3proxy'; + +const MB = 1024 * 1024; + +export interface RuntimeSnapshot { + status: Exclude; + pid: number | null; + startedAt: string | null; + lastError: string | null; +} + +export interface RuntimePaths { + rootDir: string; + configPath: string; + counterPath: string; + reportDir: string; + logPath: string; + pidPath: string; +} + +export function createDefaultState(): ControlPlaneState { + return structuredClone(dashboardSnapshot); +} + +export function buildRuntimePaths(rootDir: string): RuntimePaths { + return { + rootDir, + configPath: path.join(rootDir, 'generated', '3proxy.cfg'), + counterPath: path.join(rootDir, 'state', 'counters.3cf'), + reportDir: path.join(rootDir, 'state', 'reports'), + logPath: path.join(rootDir, 'logs', '3proxy.log'), + pidPath: path.join(rootDir, '3proxy.pid'), + }; +} + +export function validateCreateUserInput( + input: Partial, + services: ProxyServiceRecord[], +): CreateUserInput { + const username = input.username?.trim() ?? ''; + const password = input.password?.trim() ?? ''; + const serviceId = input.serviceId?.trim() ?? ''; + const quotaMb = input.quotaMb ?? null; + + assertSafeToken(username, 'Username'); + assertSafeToken(password, 'Password'); + + if (!serviceId) { + throw new Error('Service is required.'); + } + + const service = services.find((entry) => entry.id === serviceId); + if (!service || !service.enabled || !service.assignable) { + throw new Error('Service must reference an enabled assignable entry.'); + } + + if (quotaMb !== null && (!Number.isFinite(quotaMb) || quotaMb <= 0 || !Number.isInteger(quotaMb))) { + throw new Error('Quota must be a positive integer number of megabytes.'); + } + + return { + username, + password, + serviceId, + quotaMb, + }; +} + +export function createUserRecord(state: ControlPlaneState, input: CreateUserInput): ProxyUserRecord { + if (state.userRecords.some((user) => user.username === input.username)) { + throw new Error('Username already exists.'); + } + + return { + id: `u-${Math.random().toString(36).slice(2, 10)}`, + username: input.username, + password: input.password, + serviceId: input.serviceId, + status: 'idle', + paused: false, + usedBytes: 0, + quotaBytes: input.quotaMb === null ? null : input.quotaMb * MB, + }; +} + +export function render3proxyConfig(state: ControlPlaneState, paths: RuntimePaths): string { + const lines = [ + `pidfile ${normalizePath(paths.pidPath)}`, + `monitor ${normalizePath(paths.configPath)}`, + `log ${normalizePath(paths.logPath)} D`, + 'auth strong', + ]; + + const activeUsers = state.userRecords.filter((user) => !user.paused); + if (activeUsers.length > 0) { + lines.push(`users ${activeUsers.map(renderUserCredential).join(' ')}`); + } + + const quotaUsers = activeUsers.filter((user) => user.quotaBytes !== null); + if (quotaUsers.length > 0) { + lines.push( + `counter ${normalizePath(paths.counterPath)} D ${normalizePath( + path.join(paths.reportDir, '%Y-%m-%d.txt'), + )}`, + ); + + quotaUsers.forEach((user, index) => { + lines.push( + `countall ${index + 1} D ${Math.ceil((user.quotaBytes ?? 0) / MB)} ${user.username} * * * * * *`, + ); + }); + } + + state.system.services + .filter((service) => service.enabled) + .forEach((service) => { + lines.push('', 'flush'); + + if (service.assignable) { + const usernames = activeUsers + .filter((user) => user.serviceId === service.id) + .map((user) => user.username); + + lines.push(usernames.length > 0 ? `allow ${usernames.join(',')}` : 'deny *'); + } else { + lines.push('allow *'); + } + + lines.push(renderServiceCommand(service)); + }); + + return `${lines.join('\n')}\n`; +} + +export function deriveDashboardSnapshot( + state: ControlPlaneState, + runtime: RuntimeSnapshot, + previewConfig: string, +): DashboardSnapshot { + const liveUsers = state.userRecords.filter((user) => !user.paused && user.status === 'live').length; + const exceededUsers = state.userRecords.filter( + (user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes, + ).length; + const nearQuotaUsers = state.userRecords.filter((user) => { + if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) { + return false; + } + + return user.usedBytes / user.quotaBytes >= 0.8; + }).length; + + const attention = []; + + if (exceededUsers > 0) { + attention.push({ + level: 'fail' as const, + title: 'Quota exceeded', + message: `${exceededUsers} user profiles crossed their configured quota.`, + }); + } + + if (nearQuotaUsers > 0) { + attention.push({ + level: 'warn' as const, + title: 'Quota pressure detected', + message: `${nearQuotaUsers} user profiles are above 80% of their quota.`, + }); + } + + if (runtime.status === 'live') { + attention.push({ + level: 'live' as const, + title: '3proxy runtime online', + message: 'Backend control plane is currently attached to a live 3proxy process.', + }); + } else if (runtime.lastError) { + attention.push({ + level: 'fail' as const, + title: 'Runtime issue detected', + message: runtime.lastError, + }); + } + + if (attention.length === 0) { + attention.push({ + level: 'live' as const, + title: 'State loaded', + message: 'No critical runtime or quota issues are currently detected.', + }); + } + + return { + service: { + status: runtime.status, + pidLabel: runtime.pid ? `pid ${runtime.pid}` : 'pid -', + versionLabel: state.service.versionLabel, + uptimeLabel: formatUptime(runtime.startedAt), + lastEvent: state.service.lastEvent, + }, + traffic: { + ...state.traffic, + activeUsers: state.userRecords.filter((user) => !user.paused).length, + }, + users: { + total: state.userRecords.length, + live: liveUsers, + nearQuota: nearQuotaUsers, + exceeded: exceededUsers, + }, + attention, + userRecords: state.userRecords, + system: { + ...state.system, + previewConfig, + }, + }; +} + +function renderUserCredential(user: ProxyUserRecord): string { + return `${user.username}:CL:${user.password}`; +} + +function renderServiceCommand(service: ProxyServiceRecord): string { + if (service.command === 'admin') { + return `admin -p${service.port} -s`; + } + + if (service.command === 'socks') { + return `socks -p${service.port} -u2`; + } + + return `proxy -p${service.port}`; +} + +function assertSafeToken(value: string, label: string): void { + if (!value) { + throw new Error(`${label} is required.`); + } + + if (!/^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/.test(value)) { + throw new Error(`${label} contains unsupported characters.`); + } +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/'); +} + +function formatUptime(startedAt: string | null): string { + if (!startedAt) { + return 'uptime -'; + } + + const started = new Date(startedAt).getTime(); + const diffMs = Date.now() - started; + + if (!Number.isFinite(diffMs) || diffMs <= 0) { + return 'uptime 0m'; + } + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + return `uptime ${hours}h ${minutes}m`; +} diff --git a/server/lib/runtime.ts b/server/lib/runtime.ts new file mode 100644 index 0000000..3461811 --- /dev/null +++ b/server/lib/runtime.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import type { RuntimePaths, RuntimeSnapshot } from './config'; + +export interface RuntimeController { + getSnapshot(): RuntimeSnapshot; + start(): Promise; + restart(): Promise; + reload(): Promise; +} + +export class ThreeProxyManager implements RuntimeController { + private child: ChildProcess | null = null; + private snapshot: RuntimeSnapshot = { + status: 'idle', + pid: null, + startedAt: null, + lastError: null, + }; + + constructor( + private readonly binaryPath: string, + private readonly configPath: string, + private readonly paths: RuntimePaths, + private readonly autoStart: boolean, + ) {} + + async initialize(): Promise { + await fs.mkdir(path.dirname(this.paths.configPath), { recursive: true }); + await fs.mkdir(path.dirname(this.paths.counterPath), { recursive: true }); + await fs.mkdir(this.paths.reportDir, { recursive: true }); + await fs.mkdir(path.dirname(this.paths.logPath), { recursive: true }); + + if (this.autoStart) { + await this.start(); + } + } + + getSnapshot(): RuntimeSnapshot { + return { ...this.snapshot }; + } + + async start(): Promise { + if (this.child && this.child.exitCode === null) { + return this.getSnapshot(); + } + + await ensureBinaryExists(this.binaryPath); + + const child = spawn(this.binaryPath, [this.configPath], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + this.child = child; + this.snapshot = { + status: 'live', + pid: child.pid ?? null, + startedAt: new Date().toISOString(), + lastError: null, + }; + + child.stdout.on('data', (chunk) => process.stdout.write(`[3proxy] ${chunk}`)); + child.stderr.on('data', (chunk) => process.stderr.write(`[3proxy] ${chunk}`)); + child.on('exit', (code, signal) => { + this.child = null; + this.snapshot = { + status: code === 0 || signal === 'SIGTERM' ? 'idle' : 'fail', + pid: null, + startedAt: null, + lastError: + code === 0 || signal === 'SIGTERM' + ? null + : `3proxy exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`, + }; + }); + + return this.getSnapshot(); + } + + async restart(): Promise { + await this.stop(); + return this.start(); + } + + async reload(): Promise { + if (!this.child || this.child.exitCode !== null || !this.child.pid) { + return this.start(); + } + + process.kill(this.child.pid, 'SIGUSR1'); + return this.getSnapshot(); + } + + private async stop(): Promise { + if (!this.child || this.child.exitCode !== null || !this.child.pid) { + this.child = null; + return; + } + + const current = this.child; + const waitForExit = new Promise((resolve) => { + current.once('exit', () => resolve()); + }); + + current.kill('SIGTERM'); + await waitForExit; + } +} + +async function ensureBinaryExists(binaryPath: string): Promise { + try { + await fs.access(binaryPath); + } catch { + throw new Error(`3proxy binary not found at ${binaryPath}.`); + } +} diff --git a/server/lib/store.ts b/server/lib/store.ts new file mode 100644 index 0000000..4f2451d --- /dev/null +++ b/server/lib/store.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { ControlPlaneState } from '../../src/shared/contracts'; +import { createDefaultState } from './config'; + +export class StateStore { + constructor(private readonly statePath: string) {} + + async read(): Promise { + await fs.mkdir(path.dirname(this.statePath), { recursive: true }); + + try { + const raw = await fs.readFile(this.statePath, 'utf8'); + return JSON.parse(raw) as ControlPlaneState; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + + const fallback = createDefaultState(); + await this.write(fallback); + return fallback; + } + } + + async write(state: ControlPlaneState): Promise { + await fs.mkdir(path.dirname(this.statePath), { recursive: true }); + await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + } +} diff --git a/src/App.tsx b/src/App.tsx index fa88e83..b06872e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import { FormEvent, KeyboardEvent, useEffect, useState } from 'react'; +import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react'; import './app.css'; -import { dashboardSnapshot, panelAuth } from './data/mockDashboard'; +import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard'; import { buildProxyLink, formatBytes, @@ -9,6 +9,12 @@ import { getServiceTone, isQuotaExceeded, } from './lib/3proxy'; +import type { + CreateUserInput, + DashboardSnapshot, + ProxyServiceRecord, + ProxyUserRecord, +} from './shared/contracts'; type TabId = 'dashboard' | 'users' | 'system'; @@ -18,18 +24,6 @@ const tabs: Array<{ id: TabId; label: string }> = [ { id: 'system', label: 'System' }, ]; -const assignableServices = dashboardSnapshot.system.services.filter( - (service) => service.enabled && service.assignable, -); - -const servicesById = new Map( - dashboardSnapshot.system.services.map((service) => [service.id, service] as const), -); - -type UserRow = (typeof dashboardSnapshot.userRecords)[number] & { - paused?: boolean; -}; - function LoginGate({ onUnlock }: { onUnlock: () => void }) { const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); @@ -82,8 +76,22 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) { ); } -function AddUserModal({ onClose }: { onClose: () => void }) { - const [serviceId, setServiceId] = useState(assignableServices[0]?.id ?? ''); +function AddUserModal({ + host, + services, + onClose, + onCreate, +}: { + host: string; + services: ProxyServiceRecord[]; + onClose: () => void; + onCreate: (input: CreateUserInput) => Promise; +}) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [serviceId, setServiceId] = useState(services[0]?.id ?? ''); + const [quotaMb, setQuotaMb] = useState(''); + const [error, setError] = useState(''); useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { @@ -101,7 +109,23 @@ function AddUserModal({ onClose }: { onClose: () => void }) { event.stopPropagation(); }; - const selectedService = servicesById.get(serviceId); + const selectedService = services.find((service) => service.id === serviceId); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + try { + await onCreate({ + username, + password, + serviceId, + quotaMb: quotaMb.trim() ? Number(quotaMb) : null, + }); + onClose(); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to create user.'); + } + }; return (
@@ -118,19 +142,19 @@ function AddUserModal({ onClose }: { onClose: () => void }) { Close
-
+
Endpoint - - {selectedService - ? `${dashboardSnapshot.system.publicHost}:${selectedService.port}` - : 'Unavailable'} - + {selectedService ? `${host}:${selectedService.port}` : 'Unavailable'}
Protocol {selectedService ? selectedService.protocol : 'Unavailable'}
+ {error ?

{error}

: null}
- +
@@ -172,7 +193,7 @@ function ConfirmDeleteModal({ }: { username: string; onClose: () => void; - onConfirm: () => void; + onConfirm: () => Promise; }) { useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { @@ -219,37 +240,45 @@ function ConfirmDeleteModal({ ); } -function DashboardTab() { - const serviceTone = getServiceTone(dashboardSnapshot.service.status); +function DashboardTab({ + snapshot, + onRuntimeAction, +}: { + snapshot: DashboardSnapshot; + onRuntimeAction: (action: 'start' | 'restart') => Promise; +}) { + const serviceTone = getServiceTone(snapshot.service.status); return (

Service

- {dashboardSnapshot.service.status} + {snapshot.service.status}
Process
-
{dashboardSnapshot.service.pidLabel}
+
{snapshot.service.pidLabel}
Version
-
{dashboardSnapshot.service.versionLabel}
+
{snapshot.service.versionLabel}
Uptime
-
{dashboardSnapshot.service.uptimeLabel}
+
{snapshot.service.uptimeLabel}
Last event
-
{dashboardSnapshot.service.lastEvent}
+
{snapshot.service.lastEvent}
- - +
@@ -262,15 +291,15 @@ function DashboardTab() {
Total - {formatBytes(dashboardSnapshot.traffic.totalBytes)} + {formatBytes(snapshot.traffic.totalBytes)}
Connections - {dashboardSnapshot.traffic.liveConnections} + {snapshot.traffic.liveConnections}
Active users - {dashboardSnapshot.traffic.activeUsers} + {snapshot.traffic.activeUsers}
@@ -280,7 +309,7 @@ function DashboardTab() {

Daily usage

- {dashboardSnapshot.traffic.daily.map((bucket) => ( + {snapshot.traffic.daily.map((bucket) => (
{bucket.day}
@@ -297,7 +326,7 @@ function DashboardTab() {

Attention

- {dashboardSnapshot.attention.map((item) => ( + {snapshot.attention.map((item) => (
@@ -312,44 +341,35 @@ function DashboardTab() { ); } -function UsersTab() { +function UsersTab({ + snapshot, + onCreateUser, + onTogglePause, + onDeleteUser, +}: { + snapshot: DashboardSnapshot; + onCreateUser: (input: CreateUserInput) => Promise; + onTogglePause: (userId: string) => Promise; + onDeleteUser: (userId: string) => Promise; +}) { const [copiedId, setCopiedId] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [users, setUsers] = useState(() => dashboardSnapshot.userRecords); const [deleteTargetId, setDeleteTargetId] = useState(null); + const servicesById = useMemo( + () => new Map(snapshot.system.services.map((service) => [service.id, service] as const)), + [snapshot.system.services], + ); + + const assignableServices = snapshot.system.services.filter((service) => service.enabled && service.assignable); + const deleteTarget = snapshot.userRecords.find((user) => user.id === deleteTargetId) ?? null; + const handleCopy = async (userId: string, proxyLink: string) => { await navigator.clipboard.writeText(proxyLink); setCopiedId(userId); window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200); }; - const handleTogglePause = (userId: string) => { - setUsers((current) => - current.map((user) => (user.id === userId ? { ...user, paused: !user.paused } : user)), - ); - }; - - const handleDelete = () => { - if (!deleteTargetId) { - return; - } - - setUsers((current) => current.filter((user) => user.id !== deleteTargetId)); - setDeleteTargetId(null); - }; - - const deleteTarget = users.find((user) => user.id === deleteTargetId) ?? null; - const liveUsers = users.filter((user) => !user.paused && user.status === 'live').length; - const nearQuotaUsers = users.filter((user) => { - if (user.paused || user.quotaBytes === null || isQuotaExceeded(user.usedBytes, user.quotaBytes)) { - return false; - } - - return user.usedBytes / user.quotaBytes >= 0.8; - }).length; - const exceededUsers = users.filter((user) => isQuotaExceeded(user.usedBytes, user.quotaBytes)).length; - return ( <>
@@ -357,13 +377,13 @@ function UsersTab() {

Users

-

{users.length} accounts in current profile

+

{snapshot.userRecords.length} accounts in current profile

- {liveUsers} live - {nearQuotaUsers} near quota - {exceededUsers} exceeded + {snapshot.users.live} live + {snapshot.users.nearQuota} near quota + {snapshot.users.exceeded} exceeded
@@ -460,19 +480,30 @@ function UsersTab() {
- {isModalOpen ? setIsModalOpen(false)} /> : null} + {isModalOpen ? ( + setIsModalOpen(false)} + onCreate={onCreateUser} + /> + ) : null} + {deleteTarget ? ( setDeleteTargetId(null)} - onConfirm={handleDelete} + onConfirm={async () => { + await onDeleteUser(deleteTarget.id); + setDeleteTargetId(null); + }} /> ) : null} ); } -function SystemTab() { +function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) { return (
@@ -482,15 +513,15 @@ function SystemTab() {
Config mode
-
{dashboardSnapshot.system.configMode}
+
{snapshot.system.configMode}
Reload
-
{dashboardSnapshot.system.reloadMode}
+
{snapshot.system.reloadMode}
Storage
-
{dashboardSnapshot.system.storageMode}
+
{snapshot.system.storageMode}
@@ -500,7 +531,7 @@ function SystemTab() {

Services

- {dashboardSnapshot.system.services.map((service) => ( + {snapshot.system.services.map((service) => (
{service.name} @@ -521,7 +552,7 @@ function SystemTab() {

Generated config

-
{dashboardSnapshot.system.previewConfig}
+
{snapshot.system.previewConfig}
); @@ -530,30 +561,139 @@ function SystemTab() { export default function App() { const [isAuthed, setIsAuthed] = useState(false); const [activeTab, setActiveTab] = useState('dashboard'); + const [snapshot, setSnapshot] = useState(fallbackDashboardSnapshot); + + useEffect(() => { + let cancelled = false; + + void fetch('/api/state') + .then((response) => (response.ok ? response.json() : Promise.reject(new Error('API unavailable')))) + .then((payload: DashboardSnapshot) => { + if (!cancelled) { + setSnapshot(payload); + } + }) + .catch(() => { + // Keep fallback snapshot for local UI and tests when backend is not running. + }); + + return () => { + cancelled = true; + }; + }, []); if (!isAuthed) { return setIsAuthed(true)} />; } + const mutateSnapshot = async ( + request: () => Promise, + fallback: (current: DashboardSnapshot) => DashboardSnapshot, + ) => { + try { + const response = await request(); + if (response.ok) { + const payload = (await response.json()) as DashboardSnapshot; + setSnapshot(payload); + return; + } + } catch { + // Fall back to local optimistic state when the API is unavailable. + } + + setSnapshot((current) => fallback(current)); + }; + + const handleRuntimeAction = async (action: 'start' | 'restart') => { + await mutateSnapshot( + () => fetch(`/api/runtime/${action}`, { method: 'POST' }), + (current) => + withDerivedSnapshot({ + ...current, + service: { + ...current.service, + status: 'live', + lastEvent: action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel', + }, + }), + ); + }; + + const handleCreateUser = async (input: CreateUserInput) => { + await mutateSnapshot( + () => + fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }), + (current) => { + const nextUser: ProxyUserRecord = { + id: `u-${Math.random().toString(36).slice(2, 10)}`, + username: input.username.trim(), + password: input.password.trim(), + serviceId: input.serviceId, + status: 'idle', + paused: false, + usedBytes: 0, + quotaBytes: input.quotaMb === null ? null : input.quotaMb * 1024 * 1024, + }; + + return withDerivedSnapshot({ + ...current, + service: { + ...current.service, + lastEvent: `User ${nextUser.username} created from panel`, + }, + userRecords: [...current.userRecords, nextUser], + }); + }, + ); + }; + + const handleTogglePause = async (userId: string) => { + await mutateSnapshot( + () => fetch(`/api/users/${userId}/pause`, { method: 'POST' }), + (current) => + withDerivedSnapshot({ + ...current, + userRecords: current.userRecords.map((user) => + user.id === userId ? { ...user, paused: !user.paused } : user, + ), + }), + ); + }; + + const handleDeleteUser = async (userId: string) => { + await mutateSnapshot( + () => fetch(`/api/users/${userId}`, { method: 'DELETE' }), + (current) => + withDerivedSnapshot({ + ...current, + userRecords: current.userRecords.filter((user) => user.id !== userId), + }), + ); + }; + return (

3proxy UI

-

{dashboardSnapshot.system.publicHost}

+

{snapshot.system.publicHost}

Status - {dashboardSnapshot.service.status} + {snapshot.service.status}
Version - {dashboardSnapshot.service.versionLabel} + {snapshot.service.versionLabel}
Users - {dashboardSnapshot.users.total} + {snapshot.users.total}
@@ -571,9 +711,44 @@ export default function App() { ))} - {activeTab === 'dashboard' ? : null} - {activeTab === 'users' ? : null} - {activeTab === 'system' ? : null} + {activeTab === 'dashboard' ? : null} + {activeTab === 'users' ? ( + + ) : null} + {activeTab === 'system' ? : null}
); } + +function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot { + const live = snapshot.userRecords.filter((user) => !user.paused && user.status === 'live').length; + const exceeded = snapshot.userRecords.filter( + (user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes, + ).length; + const nearQuota = snapshot.userRecords.filter((user) => { + if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) { + return false; + } + + return user.usedBytes / user.quotaBytes >= 0.8; + }).length; + + return { + ...snapshot, + traffic: { + ...snapshot.traffic, + activeUsers: snapshot.userRecords.filter((user) => !user.paused).length, + }, + users: { + total: snapshot.userRecords.length, + live, + nearQuota, + exceeded, + }, + }; +} diff --git a/src/data/mockDashboard.ts b/src/data/mockDashboard.ts index 1a98adf..9cad935 100644 --- a/src/data/mockDashboard.ts +++ b/src/data/mockDashboard.ts @@ -1,15 +1,15 @@ +import type { ControlPlaneState, DashboardSnapshot } from '../shared/contracts'; + export const panelAuth = { login: 'admin', password: 'proxy-ui-demo', }; -export const dashboardSnapshot = { +export const dashboardSnapshot: ControlPlaneState = { service: { - status: 'live' as const, - pidLabel: 'pid 17', versionLabel: '3proxy 0.9.x', - uptimeLabel: 'uptime 6h 14m', lastEvent: 'Last graceful reload 2m ago', + startedAt: '2026-04-01T15:45:00.000Z', }, traffic: { totalBytes: 1_557_402_624, @@ -23,29 +23,6 @@ export const dashboardSnapshot = { { day: 'Fri', bytes: 547_037_696, share: 1 }, ], }, - users: { - total: 18, - live: 9, - nearQuota: 3, - exceeded: 1, - }, - attention: [ - { - level: 'warn' as const, - title: 'Quota pressure detected', - message: '3 users crossed 80% of their assigned transfer cap.', - }, - { - level: 'live' as const, - title: 'Config watcher online', - message: 'The next runtime slice will prefer graceful reload over full restart.', - }, - { - level: 'fail' as const, - title: 'Admin API not wired yet', - message: 'Buttons are UI-first placeholders until the backend control plane lands.', - }, - ], userRecords: [ { id: 'u-1', @@ -68,7 +45,7 @@ export const dashboardSnapshot = { { id: 'u-3', username: 'lab-unlimited', - password: 'open lane', + password: 'open-lane', serviceId: 'socks-lab', status: 'idle' as const, usedBytes: 42_844_160, @@ -91,59 +68,80 @@ export const dashboardSnapshot = { storageMode: 'flat files for config and counters', services: [ { - id: 'socks-main', - name: 'SOCKS5 main', - protocol: 'socks5', - description: 'Primary SOCKS5 entrypoint with user auth.', - port: 1080, + id: 'socks-main', + name: 'SOCKS5 main', + command: 'socks', + protocol: 'socks5', + description: 'Primary SOCKS5 entrypoint with user auth.', + port: 1080, enabled: true, assignable: true, }, { - id: 'socks-lab', - name: 'SOCKS5 lab', - protocol: 'socks5', - description: 'Secondary SOCKS5 service for lab and overflow users.', - port: 2080, + id: 'socks-lab', + name: 'SOCKS5 lab', + command: 'socks', + protocol: 'socks5', + description: 'Secondary SOCKS5 service for lab and overflow users.', + port: 2080, enabled: true, assignable: true, }, { - id: 'admin', - name: 'Admin', - protocol: 'http', - description: 'Restricted admin visibility endpoint.', - port: 8081, + id: 'admin', + name: 'Admin', + command: 'admin', + protocol: 'http', + description: 'Restricted admin visibility endpoint.', + port: 8081, enabled: true, assignable: false, }, { - id: 'proxy', - name: 'HTTP proxy', - protocol: 'http', - description: 'Optional HTTP/HTTPS proxy profile.', - port: 3128, + id: 'proxy', + name: 'HTTP proxy', + command: 'proxy', + protocol: 'http', + description: 'Optional HTTP/HTTPS proxy profile.', + port: 3128, enabled: false, assignable: true, }, ], - previewConfig: `daemon -pidfile /var/run/3proxy/3proxy.pid -monitor /etc/3proxy/generated/3proxy.cfg - -auth strong -users night-shift:CL:kettle!23 ops-east:CL:east/line - -counter /var/lib/3proxy/counters.3cf D /var/lib/3proxy/reports/%Y-%m-%d.txt -countall 1 D 1024 night-shift * * * * * * -countall 2 D 1024 ops-east * * * * * * - -flush -allow night-shift,ops-east -socks -p1080 -u2 - -flush -allow * -admin -p8081 -s`, + }, +}; + +export const fallbackDashboardSnapshot: DashboardSnapshot = { + service: { + status: 'live', + pidLabel: 'pid 17', + versionLabel: dashboardSnapshot.service.versionLabel, + uptimeLabel: 'uptime 6h 14m', + lastEvent: dashboardSnapshot.service.lastEvent, + }, + traffic: dashboardSnapshot.traffic, + users: { + total: dashboardSnapshot.userRecords.length, + live: dashboardSnapshot.userRecords.filter((user) => user.status === 'live').length, + nearQuota: 1, + exceeded: 1, + }, + attention: [ + { + level: 'warn', + title: 'Quota pressure detected', + message: 'Fallback snapshot is active until the backend API responds.', + }, + ], + userRecords: dashboardSnapshot.userRecords, + system: { + ...dashboardSnapshot.system, + previewConfig: `pidfile /runtime/3proxy.pid +monitor /runtime/generated/3proxy.cfg +auth strong +users night-shift:CL:kettle!23 ops-east:CL:east/line +flush +allow night-shift,ops-east +socks -p1080 -u2`, }, }; diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts new file mode 100644 index 0000000..4fba398 --- /dev/null +++ b/src/shared/contracts.ts @@ -0,0 +1,87 @@ +import type { ServiceState } from '../lib/3proxy'; + +export type ServiceProtocol = 'socks5' | 'http'; +export type ServiceCommand = 'socks' | 'proxy' | 'admin'; + +export interface DailyTrafficBucket { + day: string; + bytes: number; + share: number; +} + +export interface ProxyServiceRecord { + id: string; + name: string; + command: ServiceCommand; + protocol: ServiceProtocol; + description: string; + port: number; + enabled: boolean; + assignable: boolean; +} + +export interface ProxyUserRecord { + id: string; + username: string; + password: string; + serviceId: string; + status: Exclude; + usedBytes: number; + quotaBytes: number | null; + paused?: boolean; +} + +export interface ControlPlaneState { + service: { + versionLabel: string; + lastEvent: string; + startedAt: string | null; + }; + traffic: { + totalBytes: number; + liveConnections: number; + activeUsers: number; + daily: DailyTrafficBucket[]; + }; + userRecords: ProxyUserRecord[]; + system: { + publicHost: string; + configMode: string; + reloadMode: string; + storageMode: string; + services: ProxyServiceRecord[]; + }; +} + +export interface DashboardSnapshot { + service: { + status: ServiceState; + pidLabel: string; + versionLabel: string; + uptimeLabel: string; + lastEvent: string; + }; + traffic: ControlPlaneState['traffic']; + users: { + total: number; + live: number; + nearQuota: number; + exceeded: number; + }; + attention: Array<{ + level: Exclude; + title: string; + message: string; + }>; + userRecords: ProxyUserRecord[]; + system: ControlPlaneState['system'] & { + previewConfig: string; + }; +} + +export interface CreateUserInput { + username: string; + password: string; + serviceId: string; + quotaMb: number | null; +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..7d7e37d --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["server/**/*.ts", "src/shared/**/*.ts", "src/data/mockDashboard.ts"] +}