diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9076b07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +lib +admin-ui/node_modules +admin-ui/dist +.env +*.log +.git +docs +test diff --git a/.env.example b/.env.example index 8c8be77..b74bd4f 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,11 @@ SECURE=false SSL_KEY= SSL_CERT= # Timeout for cache service. Default is 5000 ms -TIMEOUT=7000 \ No newline at end of file +TIMEOUT=7000 + +# Admin API shared secret for /__osham/admin/* endpoints +OSHAM_ADMIN_SECRET= +# Allow insecure admin access from localhost only (development/test only) +OSHAM_ADMIN_ALLOW_INSECURE_LOCAL=false +# Maximum accepted admin JSON request body size in bytes (default 1048576 = 1 MiB) +OSHAM_ADMIN_MAX_BODY_BYTES=1048576 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fb8430..dace83b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,20 @@ jobs: - name: Test run: npm test + build-admin-ui: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install admin-ui dependencies + run: cd admin-ui && npm ci + + - name: Build admin-ui + run: cd admin-ui && npm run build diff --git a/.gitignore b/.gitignore index b27cb80..2006434 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,16 @@ typings/ lib +admin-ui/dist +admin-ui/src/**/*.js +admin-ui/src/**/*.d.ts +admin-ui/vite.config.js +admin-ui/vite.config.d.ts +admin-ui/tsconfig.tsbuildinfo + cache-config.yml +.osham-admin-history/ +.osham-admin-audit.jsonl .vscode .temp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ed2913 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Stage 1: Build admin-ui +FROM node:20-alpine AS admin-ui-builder +WORKDIR /build/admin-ui +COPY admin-ui/package*.json ./ +RUN npm ci +COPY admin-ui/ ./ +RUN npm run build + +# Stage 2: Build server +FROM node:20-alpine AS server-builder +WORKDIR /build +COPY package*.json ./ +RUN npm ci +COPY src/ ./src/ +COPY tsconfig.json ./ +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY --from=server-builder /build/lib ./lib +COPY --from=admin-ui-builder /build/admin-ui/dist ./admin-ui/dist +COPY bin/ ./bin/ + +EXPOSE 26192 +CMD ["node", "lib/index.js"] diff --git a/README.md b/README.md index 181816b..f99391c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ SECURE=false TIMEOUT=7000 ``` -See `cache-config.example.yml` for full options and examples. The server supports per-namespace rules, cache expiry, pooling, and query/header-based cache variation. +The server supports per-namespace rules, cache expiry, pooling, and query/header-based cache variation. See the inline example below and the full config reference in the docs. ### Example `cache-config.yml` @@ -74,6 +74,40 @@ dummyRest: cache: false ``` +## Allow / Deny URL patterns + +Each namespace accepts optional `allow` and `deny` glob pattern lists that control which paths Osham proxies. Requests blocked by these rules receive a `403` response with the header `x-osham-cache: denied`. + +**Precedence rules:** + +- If `deny` is set and the path matches any pattern → **403 Forbidden** (deny always wins). +- Else if `allow` is set and the path does **not** match any pattern → **403 Forbidden**. +- If neither `allow` nor `deny` is present, all paths within the namespace are handled normally (existing behavior unchanged). + +**Example:** + +```yaml +myNs: + expose: '/api/v1/*' + target: 'http://localhost:3000' + cache: + expires: 10s + allow: + - '/employees/**' + - '/employee/*' + deny: + - '/employees/private/**' +``` + +In this example: + +- `/employees/123` → proxied (matches allow) +- `/employee/5` → proxied (matches allow) +- `/employees/private/data` → **403** (deny wins, even though it also matches `/employees/**` in allow) +- `/departments/1` → **403** (not in allow list) + +Patterns follow glob syntax (e.g. `*` matches a single path segment, `**` matches any number of segments). + ## Purge cache (administrative) Osham provides an admin endpoint to invalidate cache by exact key or by pattern. See the detailed guide: @@ -104,6 +138,30 @@ Scrape this endpoint from your Prometheus instance to track cache efficiency and - [Metrics](docs/metrics.md) +## Admin UI + +Osham now includes a sidecar admin UI under `admin-ui/` for config editing, health/metrics visibility, purge tooling, audit review, config history, rollback, and draft import/export. + +### Run the admin UI locally + +Start Osham first so the admin API is reachable, then in another shell: + +```sh +cd admin-ui +npm install +npm run build +# or for local development +npm run dev +``` + +By default the Vite dev server proxies `'/__osham/*'` requests to `http://127.0.0.1:26192`. If your Osham server listens elsewhere, override the proxy target: + +```sh +OSHAM_ADMIN_API_TARGET=http://127.0.0.1:3001 npm run dev +``` + +The UI stores `x-osham-admin-secret` in `sessionStorage` only and will prompt again if the saved secret is rejected by the admin API. + ## When to use Osham - Reduce backend load and TTFB for high-read API endpoints @@ -127,11 +185,26 @@ The diagram below illustrates how Osham handles incoming HTTP GET requests: ![Osham Architecture](https://raw.githubusercontent.com/ajaysinghj8/osham/master/public/Arch.svg?sanitize=true&raw=true) +## Troubleshooting + +Having problems? See the [Troubleshooting guide](docs/troubleshooting.md) for solutions to common issues including: + +- Server won't start (missing config, invalid YAML, HTTPS env vars) +- Cache misses or incorrect TTL behaviour +- 403 responses from allow/deny rules +- Purge not working (auth, pattern format) +- Metrics endpoint returning 404 +- Admin UI auth and CORS issues + ## Contributing -PRs and issues welcome. Run tests with: +PRs and issues welcome. See the [Contributor Setup guide](docs/contributor-setup.md) for full local setup instructions, test guidance, and PR guidelines. + +Quick start: ```sh +npm install +npm run build npm test ``` diff --git a/admin-ui/index.html b/admin-ui/index.html new file mode 100644 index 0000000..b356922 --- /dev/null +++ b/admin-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Osham Admin + + +
+ + + diff --git a/admin-ui/package-lock.json b/admin-ui/package-lock.json new file mode 100644 index 0000000..61a5b8e --- /dev/null +++ b/admin-ui/package-lock.json @@ -0,0 +1,1804 @@ +{ + "name": "osham-admin-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "osham-admin-ui", + "version": "0.1.0", + "dependencies": { + "js-yaml": "^4.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/admin-ui/package.json b/admin-ui/package.json new file mode 100644 index 0000000..3e717f5 --- /dev/null +++ b/admin-ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "osham-admin-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "clean": "find src -type f \\( -name '*.js' -o -name '*.d.ts' \\) -delete && rm -f vite.config.js vite.config.d.ts tsconfig.tsbuildinfo", + "build": "npm run clean && tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/admin-ui/src/api.ts b/admin-ui/src/api.ts new file mode 100644 index 0000000..c37a41b --- /dev/null +++ b/admin-ui/src/api.ts @@ -0,0 +1,138 @@ +export const ADMIN_SECRET_STORAGE_KEY = 'osham-admin-secret'; +export const ADMIN_SECRET_OPTIONAL_STORAGE_KEY = 'osham-admin-auth-optional'; +export const ADMIN_SECRET_CHANGED_EVENT = 'osham-admin-secret-changed'; +export const ADMIN_AUTH_REQUIRED_EVENT = 'osham-admin-auth-required'; + +export class ApiError extends Error { + status: number; + code?: string; + details?: unknown; + + constructor(message: string, options: { status: number; code?: string; details?: unknown }) { + super(message); + this.name = 'ApiError'; + this.status = options.status; + this.code = options.code; + this.details = options.details; + } +} + +function emitAdminSecretChanged(): void { + window.dispatchEvent(new Event(ADMIN_SECRET_CHANGED_EVENT)); +} + +function emitAdminAuthRequired(message: string): void { + window.dispatchEvent(new CustomEvent(ADMIN_AUTH_REQUIRED_EVENT, { detail: { message } })); +} + +export function requiresAdminAuth(): boolean { + return window.sessionStorage.getItem(ADMIN_SECRET_OPTIONAL_STORAGE_KEY) !== 'true'; +} + +export function markAdminAuthOptional(): void { + window.sessionStorage.setItem(ADMIN_SECRET_OPTIONAL_STORAGE_KEY, 'true'); + emitAdminSecretChanged(); +} + +export function getStoredAdminSecret(): string { + const sessionSecret = window.sessionStorage.getItem(ADMIN_SECRET_STORAGE_KEY) || ''; + if (sessionSecret) return sessionSecret; + + const legacySecret = window.localStorage.getItem(ADMIN_SECRET_STORAGE_KEY) || ''; + if (legacySecret) { + window.sessionStorage.setItem(ADMIN_SECRET_STORAGE_KEY, legacySecret); + window.localStorage.removeItem(ADMIN_SECRET_STORAGE_KEY); + emitAdminSecretChanged(); + } + return legacySecret; +} + +export function saveAdminSecret(secret: string): void { + const trimmed = secret.trim(); + if (trimmed) { + window.sessionStorage.setItem(ADMIN_SECRET_STORAGE_KEY, trimmed); + } else { + window.sessionStorage.removeItem(ADMIN_SECRET_STORAGE_KEY); + } + window.sessionStorage.removeItem(ADMIN_SECRET_OPTIONAL_STORAGE_KEY); + window.localStorage.removeItem(ADMIN_SECRET_STORAGE_KEY); + emitAdminSecretChanged(); +} + +export function clearAdminSecret(): void { + window.sessionStorage.removeItem(ADMIN_SECRET_STORAGE_KEY); + window.sessionStorage.removeItem(ADMIN_SECRET_OPTIONAL_STORAGE_KEY); + window.localStorage.removeItem(ADMIN_SECRET_STORAGE_KEY); + emitAdminSecretChanged(); +} + +async function readJsonResponse(res: Response): Promise { + let body: any = null; + const rawText = await res.text(); + if (rawText) { + try { + body = JSON.parse(rawText); + } catch { + body = null; + } + } + + if (!res.ok || body?.ok === false) { + const unauthorizedMessage = getStoredAdminSecret() + ? 'Admin API rejected the saved secret. Update or clear the x-osham-admin-secret and try again.' + : 'Admin API requires x-osham-admin-secret. Save the admin secret and retry, or continue without one only if insecure local admin access is enabled.'; + + if (res.status === 401) { + if (getStoredAdminSecret()) { + clearAdminSecret(); + } + emitAdminAuthRequired(unauthorizedMessage); + } + + throw new ApiError( + res.status === 401 ? unauthorizedMessage : body?.error?.message || rawText || `Request failed: ${res.status}`, + { + status: res.status, + code: body?.error?.code, + details: body?.details, + }, + ); + } + return body.data as T; +} + +function getAdminHeaders(contentType?: string): HeadersInit { + const secret = getStoredAdminSecret(); + return { + ...(contentType ? { 'content-type': contentType } : {}), + ...(secret ? { 'x-osham-admin-secret': secret } : {}), + }; +} + +export async function apiGet(path: string): Promise { + const res = await fetch(path, { + headers: getAdminHeaders(), + }); + + return readJsonResponse(res); +} + +export async function apiPost(path: string, payload: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + headers: getAdminHeaders('application/json'), + body: JSON.stringify(payload), + }); + + return readJsonResponse(res); +} + +export async function apiPut(path: string, payload: unknown): Promise { + const res = await fetch(path, { + method: 'PUT', + headers: getAdminHeaders('application/json'), + body: JSON.stringify(payload), + }); + + return readJsonResponse(res); +} diff --git a/admin-ui/src/main.tsx b/admin-ui/src/main.tsx new file mode 100644 index 0000000..4d1b418 --- /dev/null +++ b/admin-ui/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; +import { router } from './router'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/admin-ui/src/router.tsx b/admin-ui/src/router.tsx new file mode 100644 index 0000000..fcb4bb0 --- /dev/null +++ b/admin-ui/src/router.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'; +import { DashboardPage } from './screens/DashboardPage'; +import { ConfigPage } from './screens/ConfigPage'; +import { MetricsPage } from './screens/MetricsPage'; +import { HealthPage } from './screens/HealthPage'; +import { PurgePage } from './screens/PurgePage'; +import { AuditPage } from './screens/AuditPage'; +import { NotFoundPage } from './screens/NotFoundPage'; +import { Sidebar } from './ui/Sidebar'; +import { TopBar } from './ui/TopBar'; +import { AuthGate } from './ui/AuthGate'; + +const SIDEBAR_STORAGE_KEY = 'osham-sidebar-collapsed'; + +function AdminLayout() { + const [collapsed, setCollapsed] = React.useState( + () => localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true', + ); + + function toggle() { + setCollapsed(prev => { + const next = !prev; + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next)); + return next; + }); + } + + return ( + +
+ + +
+ +
+
+
+ ); +} + +export const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: '/admin/dashboard', element: }, + { path: '/admin/config', element: }, + { path: '/admin/metrics', element: }, + { path: '/admin/health', element: }, + { path: '/admin/purge', element: }, + { path: '/admin/audit', element: }, + { path: '*', element: }, + ], + }, +]); diff --git a/admin-ui/src/screens/AuditPage.tsx b/admin-ui/src/screens/AuditPage.tsx new file mode 100644 index 0000000..4b2bfee --- /dev/null +++ b/admin-ui/src/screens/AuditPage.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Page } from '../ui/Page'; +import { apiGet } from '../api'; +import { AuditEvent } from '../types'; + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function renderDetails(event: AuditEvent): string { + if (!event.details) return '—'; + const d = event.details; + if (event.action === 'admin.purge') { + const parts: string[] = []; + if (d['pattern'] != null) parts.push(`pattern: ${String(d['pattern'])}`); + if (typeof d['deleted'] === 'number') parts.push(`deleted: ${d['deleted']}`); + if (d['dryRun']) parts.push('dry-run'); + return parts.join(', ') || '—'; + } + if (event.action === 'config.save' || event.action === 'config.reload') { + if (d['revision'] != null) return `revision: ${String(d['revision'])}`; + } + const keys = Object.keys(d); + if (keys.length === 0) return '—'; + return keys + .slice(0, 2) + .map(k => `${k}: ${String(d[k])}`) + .join(', '); +} + +export function AuditPage() { + const [events, setEvents] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + function load() { + setLoading(true); + setError(null); + apiGet('/__osham/admin/audit') + .then(data => { + setEvents(data); + setLoading(false); + }) + .catch(err => { + setError(err instanceof Error ? err.message : 'Request failed'); + setLoading(false); + }); + } + + React.useEffect(load, []); + + return ( + +
+ +
+ + {error &&
{error}
} + + {!loading && !error && events.length === 0 ? ( +

No audit events yet.

+ ) : ( + + + + + + + + + + + + {events.map((event, index) => ( + + + + + + + + ))} + +
TimeActionResultActorDetails
{formatTime(event.time)} + {event.action} + + + {event.result} + + {event.actor}{renderDetails(event)}
+ )} +
+ ); +} diff --git a/admin-ui/src/screens/ConfigPage.tsx b/admin-ui/src/screens/ConfigPage.tsx new file mode 100644 index 0000000..9a2e63a --- /dev/null +++ b/admin-ui/src/screens/ConfigPage.tsx @@ -0,0 +1,794 @@ +import React from 'react'; +import { dump, load } from 'js-yaml'; +import { ApiError, apiGet, apiPost, apiPut } from '../api'; +import { + AdminConfigSnapshot, + AdminConfigView, + CacheConfigView, + NamespaceView, + ValidationResult, +} from '../types'; +import { Page } from '../ui/Page'; + +// ─── Pure helpers (unchanged) ──────────────────────────────────── +function toLines(value: string): string[] { + return value.split('\n').map(l => l.trim()).filter(Boolean); +} +function fromLines(value?: string[]): string { + return (value || []).join('\n'); +} + +type CacheSource = + | NamespaceView['cache'] + | { expires?: string | number; pool?: boolean; query?: string[] | false; headers?: string[] | false } + | false + | undefined; + +function toCacheView(cache: CacheSource): CacheConfigView { + if (!cache) return { enabled: false, expires: '', pool: false, query: [], headers: [] }; + return { + enabled: true, + expires: cache.expires ? String(cache.expires) : '', + pool: !!cache.pool, + query: Array.isArray(cache.query) ? cache.query : [], + headers: Array.isArray(cache.headers) ? cache.headers : [], + }; +} + +function toRuleViews( + rules: NamespaceView['rules'] | Record | undefined, +): NamespaceView['rules'] { + if (!rules) return []; + if (Array.isArray(rules)) return rules.map(r => ({ pattern: r.pattern, cache: toCacheView(r.cache) })); + return Object.entries(rules).map(([pattern, rule]) => ({ pattern, cache: toCacheView(rule?.cache) })); +} + +function normalizeConfig(data: AdminConfigView): AdminConfigView { + return { + ...data, + namespaces: Object.fromEntries( + Object.entries(data.namespaces || {}).map(([name, ns]) => [ + name, + { + expose: ns.expose || '', + target: ns.target || '', + port: ns.port ? String(ns.port) : '', + timeout: ns.timeout ? String(ns.timeout) : '', + followRedirects: !!ns.followRedirects, + changeOrigin: !!ns.changeOrigin, + allow: ns.allow || [], + deny: ns.deny || [], + cache: toCacheView(ns.cache), + rules: toRuleViews(ns.rules), + }, + ]), + ), + }; +} + +function buildPayload(config: AdminConfigView) { + const namespaces = Object.fromEntries( + Object.entries(config.namespaces).map(([name, ns]) => { + const built: Record = { expose: ns.expose, target: ns.target }; + if (ns.port) built.port = Number(ns.port); + if (ns.timeout) built.timeout = Number(ns.timeout); + if (ns.followRedirects) built.followRedirects = true; + if (ns.changeOrigin) built.changeOrigin = true; + if (ns.allow.length) built.allow = ns.allow; + if (ns.deny.length) built.deny = ns.deny; + if (ns.cache.enabled) { + built.cache = { + ...(ns.cache.expires ? { expires: ns.cache.expires } : {}), + ...(ns.cache.pool ? { pool: true } : {}), + ...(ns.cache.query.length ? { query: ns.cache.query } : {}), + ...(ns.cache.headers.length ? { headers: ns.cache.headers } : {}), + }; + } else { + built.cache = false; + } + const validRules = ns.rules.filter(r => r.pattern?.trim()); + if (validRules.length) { + built.rules = Object.fromEntries( + validRules.map(r => [ + r.pattern, + { + cache: r.cache.enabled + ? { + ...(r.cache.expires ? { expires: r.cache.expires } : {}), + ...(r.cache.pool ? { pool: true } : {}), + ...(r.cache.query.length ? { query: r.cache.query } : {}), + ...(r.cache.headers.length ? { headers: r.cache.headers } : {}), + } + : false, + }, + ]), + ); + } + return [name, built]; + }), + ); + return { + globalConfig: { + version: config.globalConfig.version, + xResponseTime: config.globalConfig.xResponseTime, + health: config.globalConfig.health, + purge: config.globalConfig.purge, + metrics: config.globalConfig.metrics, + changeOrigin: config.globalConfig.changeOrigin, + }, + namespaces, + }; +} + +function cloneNamespaceView(ns: NamespaceView): NamespaceView { + return { + ...ns, + allow: [...ns.allow], + deny: [...ns.deny], + cache: { ...ns.cache, query: [...ns.cache.query], headers: [...ns.cache.headers] }, + rules: ns.rules.map(r => ({ pattern: r.pattern, cache: { ...r.cache, query: [...r.cache.query], headers: [...r.cache.headers] } })), + }; +} + +function isAdminConfigView(v: unknown): v is AdminConfigView { + if (!v || typeof v !== 'object' || Array.isArray(v)) return false; + const r = v as Record; + return !!r.globalConfig && !!r.namespaces; +} + +function toDraftAdminConfigView(parsed: unknown, currentMeta: AdminConfigView['meta'] | undefined): AdminConfigView { + const fallbackMeta = currentMeta || { source: 'imported draft', lastLoadedAt: new Date().toISOString(), lastAppliedAt: null, revision: 'imported-draft' }; + if (isAdminConfigView(parsed)) return normalizeConfig({ ...parsed, meta: parsed.meta || fallbackMeta }); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Imported file must be a JSON or YAML object.'); + const { version, xResponseTime, health, purge, metrics, changeOrigin, ...namespaces } = parsed as Record; + return normalizeConfig({ + globalConfig: { + version: typeof version === 'string' ? version : '', + xResponseTime: xResponseTime === true, + health: health === true, + purge: purge === true, + metrics: metrics === true, + changeOrigin: changeOrigin === true, + }, + namespaces: namespaces as AdminConfigView['namespaces'], + meta: fallbackMeta, + }); +} + +function downloadDraftConfig(config: AdminConfigView, format: 'json' | 'yaml') { + const payload = buildPayload(config); + const content = format === 'yaml' ? dump(payload, { noRefs: true, lineWidth: 120 }) : JSON.stringify(payload, null, 2); + const blob = new Blob([content], { type: format === 'yaml' ? 'application/x-yaml' : 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `osham-config-${config.meta.revision || 'draft'}.${format === 'yaml' ? 'yml' : 'json'}`; a.click(); + URL.revokeObjectURL(url); +} + +function getIssuesForPaths(validation: ValidationResult | null, paths: string[]): ValidationResult['errors'] { + if (!validation) return []; + const ps = paths.filter(Boolean); + return [...validation.errors, ...validation.warnings].filter(i => ps.some(p => i.field === p || i.field.startsWith(`${p}.`))); +} + +// ─── Small shared components ───────────────────────────────────── +function FieldIssues({ issues }: { issues: ValidationResult['errors'] }) { + if (!issues.length) return null; + return ( +
+ {issues.map((issue, i) => ( +
{issue.message}
+ ))} +
+ ); +} + +function ChevronDown() { + return ( + + + + ); +} + +function CollapsibleSection({ title, badge, defaultOpen = false, children }: { + title: string; badge?: string; defaultOpen?: boolean; children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────── +export function ConfigPage() { + // Config state + const [config, setConfig] = React.useState(null); + const [history, setHistory] = React.useState([]); + const [selectedNamespace, setSelectedNamespace] = React.useState(''); + const [allowText, setAllowText] = React.useState(''); + const [denyText, setDenyText] = React.useState(''); + const [rulesText, setRulesText] = React.useState('[]'); + const [validation, setValidation] = React.useState(null); + const [message, setMessage] = React.useState(null); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(null); + const [lastSavedSnapshot, setLastSavedSnapshot] = React.useState(''); + const importInputRef = React.useRef(null); + + // UI state (Phase 2) + const [tab, setTab] = React.useState<'global' | 'namespaces' | 'history'>('global'); + const [previewOpen, setPreviewOpen] = React.useState(false); + const [previewFormat, setPreviewFormat] = React.useState<'yaml' | 'json'>('yaml'); + + // ── Load ────────────────────────────────────────────────────── + const loadConfig = React.useCallback(async () => { + try { + const [configData, historyData] = await Promise.all([ + apiGet('/__osham/admin/config'), + apiGet('/__osham/admin/config/history'), + ]); + const data = normalizeConfig(configData); + setConfig(data); + setHistory(historyData); + setSelectedNamespace(current => (current && data.namespaces[current] ? current : Object.keys(data.namespaces)[0] || '')); + setValidation(null); + setError(null); + setLastSavedSnapshot(JSON.stringify(buildPayload(data), null, 2)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load config'); + } + }, []); + + React.useEffect(() => { loadConfig(); }, [loadConfig]); + + const namespace = config && selectedNamespace ? config.namespaces[selectedNamespace] : null; + + React.useEffect(() => { + if (!namespace) return; + setAllowText(fromLines(namespace.allow)); + setDenyText(fromLines(namespace.deny)); + setRulesText(JSON.stringify(namespace.rules, null, 2)); + // Only reset text areas when the user switches to a different namespace. + // Omitting `namespace` from deps intentionally — we do NOT want to reset + // the text areas whenever config mutates (e.g. after save/patch), only + // when the selection itself changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedNamespace]); + + // ── Patchers ────────────────────────────────────────────────── + function patchConfig(updater: (c: AdminConfigView) => AdminConfigView) { + setConfig(c => (c ? updater(c) : c)); + } + function patchNamespace(updater: (ns: NamespaceView) => NamespaceView) { + if (!config || !selectedNamespace) return; + patchConfig(c => ({ ...c, namespaces: { ...c.namespaces, [selectedNamespace]: updater(c.namespaces[selectedNamespace]) } })); + } + + // ── Sync + preview ──────────────────────────────────────────── + function buildSyncedConfigSnapshot(current: AdminConfigView): AdminConfigView { + if (!selectedNamespace || !current.namespaces[selectedNamespace]) return current; + const parsedRules = JSON.parse(rulesText) as NamespaceView['rules']; + if (!Array.isArray(parsedRules)) throw new Error('Rules JSON must be an array'); + return { + ...current, + namespaces: { + ...current.namespaces, + [selectedNamespace]: { + ...current.namespaces[selectedNamespace], + allow: toLines(allowText), + deny: toLines(denyText), + rules: parsedRules.map(r => ({ pattern: r.pattern, cache: toCacheView(r.cache) })), + }, + }, + }; + } + + function syncTextAreas() { + if (!config) return null; + try { + const next = buildSyncedConfigSnapshot(config); + setConfig(next); + return next; + } catch (err) { + setError(err instanceof Error ? err.message : 'Rules JSON is invalid'); + return null; + } + } + + const previewState = React.useMemo(() => { + if (!config) return null; + try { + const payload = buildPayload(buildSyncedConfigSnapshot(config)); + return { rawJson: JSON.stringify(payload, null, 2), rawYaml: dump(payload, { noRefs: true, lineWidth: 120 }), error: null as string | null }; + } catch (err) { + const payload = buildPayload(config); + return { rawJson: JSON.stringify(payload, null, 2), rawYaml: dump(payload, { noRefs: true, lineWidth: 120 }), error: err instanceof Error ? err.message : 'Rules JSON is invalid' }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allowText, config, denyText, rulesText, selectedNamespace]); + + const currentPayload = previewState?.rawJson || ''; + const hasUnsavedChanges = !!config && currentPayload !== lastSavedSnapshot; + + React.useEffect(() => { + function onBeforeUnload(e: BeforeUnloadEvent) { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ''; } } + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); + }, [hasUnsavedChanges]); + + // ── Actions ─────────────────────────────────────────────────── + function issuesForGlobalField(field: string) { + return getIssuesForPaths(validation, [field, `globalConfig.${field}`, `global.${field}`]); + } + function issuesForNamespaceField(field: string) { + if (!selectedNamespace) return []; + return getIssuesForPaths(validation, [`namespaces.${selectedNamespace}.${field}`]); + } + + async function runValidate() { + if (!config) return; + const synced = syncTextAreas(); + if (!synced) return; + setBusy('validate'); setMessage(null); setError(null); + try { + const result = await apiPost('/__osham/admin/config/validate', { config: buildPayload(synced) }); + setValidation(result); + setMessage(result.valid ? 'Validation passed.' : 'Validation failed. Review the issues below.'); + } catch (err) { setError(err instanceof Error ? err.message : 'Validation failed'); } + finally { setBusy(null); } + } + + async function runSave() { + if (!config) return; + const synced = syncTextAreas(); + if (!synced) return; + setBusy('save'); setMessage(null); setError(null); + try { + const data = await apiPut<{ saved: boolean; revision: string; warnings: { message: string }[] }>( + '/__osham/admin/config', + { config: buildPayload(synced), expectedRevision: synced.meta.revision }, + ); + setMessage(`Saved — revision ${data.revision}.`); + await loadConfig(); + if (data.warnings?.length) { + setValidation({ valid: true, errors: [], warnings: data.warnings.map(w => ({ field: 'config', message: w.message, severity: 'warning', code: 'SAVE_WARNING' })) }); + } + } catch (err) { + if (err instanceof ApiError && err.code === 'REVISION_CONFLICT') setError('Save blocked: config changed elsewhere. Refresh and re-apply your edits.'); + else setError(err instanceof Error ? err.message : 'Save failed'); + } finally { setBusy(null); } + } + + async function runReload() { + if (hasUnsavedChanges) { setError('Reload blocked — save or reset your draft first.'); return; } + setBusy('reload'); setMessage(null); setError(null); + try { + const data = await apiPost<{ applied: boolean; revision: string; note?: string }>('/__osham/admin/config/reload', { expectedRevision: config?.meta.revision }); + setMessage(data.note || `Reloaded at revision ${data.revision}.`); + await loadConfig(); + } catch (err) { + if (err instanceof ApiError && err.code === 'REVISION_CONFLICT') setError('Reload blocked: another change landed first. Refresh first.'); + else setError(err instanceof Error ? err.message : 'Reload failed'); + } finally { setBusy(null); } + } + + async function runRollback(snapshot: AdminConfigSnapshot) { + if (!config) return; + if (hasUnsavedChanges) { setError('Rollback blocked — save or reset your draft first.'); return; } + if (!window.confirm(`Roll back from ${config.meta.revision} → ${snapshot.revision}?\nThis immediately rewrites cache-config.yml.`)) return; + setBusy(`rollback:${snapshot.revision}`); setMessage(null); setError(null); + try { + const data = await apiPost<{ applied: boolean; revision: string; note?: string }>('/__osham/admin/config/rollback', { revision: snapshot.revision, expectedRevision: config.meta.revision }); + setMessage(data.note || `Rolled back to revision ${data.revision}.`); + await loadConfig(); + } catch (err) { + if (err instanceof ApiError && err.code === 'REVISION_CONFLICT') setError('Rollback blocked: another change landed first. Refresh first.'); + else setError(err instanceof Error ? err.message : 'Rollback failed'); + } finally { setBusy(null); } + } + + function addNamespace() { + const name = window.prompt('Namespace name'); + if (!name || !config || config.namespaces[name]) return; + patchConfig(c => ({ ...c, namespaces: { ...c.namespaces, [name]: { expose: '/api/*', target: 'http://localhost:3000', port: '', timeout: '', followRedirects: false, changeOrigin: false, allow: [], deny: [], cache: { enabled: true, expires: '', pool: false, query: [], headers: [] }, rules: [] } } })); + setSelectedNamespace(name); + setTab('namespaces'); + } + + function cloneNamespace() { + if (!config || !namespace || !selectedNamespace) return; + const name = window.prompt('Clone namespace as', `${selectedNamespace}-copy`); + if (!name || config.namespaces[name]) return; + patchConfig(c => ({ ...c, namespaces: { ...c.namespaces, [name]: cloneNamespaceView(c.namespaces[selectedNamespace]) } })); + setSelectedNamespace(name); + setMessage(`Cloned ${selectedNamespace} → ${name}.`); + } + + function deleteNamespace() { + if (!config || !namespace || !selectedNamespace) return; + if (!window.confirm(`Delete namespace "${selectedNamespace}"? Only affects the draft until you save.`)) return; + const names = Object.keys(config.namespaces).filter(n => n !== selectedNamespace); + patchConfig(c => { const next = { ...c.namespaces }; delete next[selectedNamespace]; return { ...c, namespaces: next }; }); + setSelectedNamespace(names[0] || ''); + setMessage(`Removed "${selectedNamespace}" from draft. Save to persist.`); + } + + function resetDraft() { + if (!hasUnsavedChanges) return; + if (!window.confirm('Discard draft changes and reload the live config?')) return; + setMessage('Draft discarded.'); setValidation(null); setError(null); + void loadConfig(); + } + + function exportDraft() { + if (!config) return; + const synced = syncTextAreas(); + if (!synced) return; + downloadDraftConfig(synced, previewFormat); + setMessage(`Exported draft as ${previewFormat.toUpperCase()}.`); setError(null); + } + + async function importDraftFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; event.target.value = ''; + if (!file) return; + try { + const text = await file.text(); + const isYaml = /\.(ya?ml)$/i.test(file.name); + const normalized = toDraftAdminConfigView((isYaml ? load(text) : JSON.parse(text)) as unknown, config?.meta); + setConfig(normalized); + setSelectedNamespace(c => (c && normalized.namespaces[c] ? c : Object.keys(normalized.namespaces)[0] || '')); + setValidation(null); setError(null); + setMessage(`Imported ${isYaml ? 'YAML' : 'JSON'} from ${file.name}. Validate before saving.`); + } catch (err) { setError(err instanceof Error ? err.message : 'Failed to import config file'); } + } + + async function copyPreview() { + if (!previewState) return; + try { + await navigator.clipboard.writeText(previewFormat === 'yaml' ? previewState.rawYaml : previewState.rawJson); + setMessage(`Copied ${previewFormat.toUpperCase()} to clipboard.`); setError(null); + } catch (err) { setError(err instanceof Error ? err.message : 'Copy failed'); } + } + + // ── Derived ─────────────────────────────────────────────────── + const deduplicatedHistory = history.reduce((acc, s) => { + if (!acc.some(x => x.revision === s.revision)) acc.push(s); + return acc; + }, []).slice(0, 15); + + const nsCount = config ? Object.keys(config.namespaces).length : 0; + + // ───────────────────────────────────────────────────────────── + return ( + + {/* Status banners */} + {error &&
{error}
} + {message &&
{message}
} + {validation && !validation.valid && ( +
+ {validation.errors.length} error{validation.errors.length !== 1 ? 's' : ''} · {validation.warnings.length} warning{validation.warnings.length !== 1 ? 's' : ''} + {' — '}{validation.errors.concat(validation.warnings).slice(0, 2).map(i => i.message).join('; ')} +
+ )} + + {/* Tab bar */} +
+ + + +
+ {hasUnsavedChanges && Unsaved changes} + {config && } +
+
+ + {!config &&
Loading config…
} + + {/* ── Global tab ── */} + {config && tab === 'global' && ( +
+
+
Revision {config.meta.revision}
+
Source {config.meta.source}
+
Loaded {new Date(config.meta.lastLoadedAt).toLocaleString()}
+
Applied {config.meta.lastAppliedAt ? new Date(config.meta.lastAppliedAt).toLocaleString() : '—'}
+
Draft {hasUnsavedChanges ? 'dirty' : 'clean'}
+
+ +
+

Version

+ +
+ +
+

Features

+
+ {([['health', 'Health'], ['metrics', 'Metrics'], ['purge', 'Purge'], ['xResponseTime', 'X-Response-Time'], ['changeOrigin', 'Change Origin']] as [string, string][]).map(([key, label]) => ( + + ))} +
+
+ Metrics path: {config.globalConfig.metricsPath || '/__osham/metrics'} + Secure: {String(config.globalConfig.secure?.enabled || false)} + SSL key: {String(config.globalConfig.secure?.sslKeyConfigured || false)} + SSL cert: {String(config.globalConfig.secure?.sslCertConfigured || false)} +
+
+ +
+ + + +
+ + +
+ + + { void importDraftFile(e); }} /> +
+
+
+ )} + + {/* ── Namespaces tab ── */} + {config && tab === 'namespaces' && ( +
+
+ {/* Left: namespace list */} +
+
Namespaces
+
+ {Object.keys(config.namespaces).map(name => ( + + ))} + {Object.keys(config.namespaces).length === 0 && ( +
No namespaces yet
+ )} +
+
+ +
+
+ + {/* Right: namespace form */} + {namespace ? ( +
+
+

{selectedNamespace}

+
+ + +
+
+ + {/* Basic */} +
+ + +
+ + {/* Cache */} + + + {namespace.cache.enabled && ( + <> +
+ + +
+
+