diff --git a/.github/workflows/publish-js-sdks.yml b/.github/workflows/publish-js-sdks.yml index bfd3173b5..d051b0a1a 100644 --- a/.github/workflows/publish-js-sdks.yml +++ b/.github/workflows/publish-js-sdks.yml @@ -42,13 +42,14 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v4 with: - version: 9.15.0 - run_install: false + version: latest + + - name: Enable corepack + run: corepack enable - name: Get pnpm store path id: pnpm-store - working-directory: sdks - run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" + run: echo "STORE_PATH=$(corepack pnpm store path)" >> "$GITHUB_OUTPUT" - name: Cache pnpm store uses: actions/cache@v5 @@ -59,11 +60,11 @@ jobs: - name: Install workspace dependencies working-directory: sdks - run: pnpm install --frozen-lockfile + run: corepack pnpm install --frozen-lockfile - name: Build SDK working-directory: sdks - run: pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build + run: corepack pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build - name: Pack SDK if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix)) @@ -73,7 +74,7 @@ jobs: set -euo pipefail PACK_DIR="${GITHUB_WORKSPACE}/dist/npm/${{ matrix.sdk.name }}" mkdir -p "$PACK_DIR" - pnpm pack --pack-destination "$PACK_DIR" + corepack pnpm pack --pack-destination "$PACK_DIR" PACKAGE_TARBALL="$(find "$PACK_DIR" -maxdepth 1 -name '*.tgz' -print -quit)" if [[ -z "$PACKAGE_TARBALL" ]]; then echo "No package tarball was produced in $PACK_DIR" >&2 @@ -92,4 +93,4 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - pnpm publish "${{ steps.pack.outputs.tarball }}" --access public --no-git-checks + corepack pnpm publish "${{ steps.pack.outputs.tarball }}" --access public --no-git-checks diff --git a/.gitignore b/.gitignore index a5e8e9307..34009cee8 100644 --- a/.gitignore +++ b/.gitignore @@ -274,5 +274,10 @@ kubernetes/test/kind/gvisor/runsc kubernetes/test/kind/gvisor/containerd-shim-runsc-v1 bin/ obj/ +console/dist/ +console/output/ +console/playwright-report/ +console/tests/playwright-report/ +console/playwright.config.ts .qoder/ diff --git a/console/index.html b/console/index.html new file mode 100644 index 000000000..492f11d99 --- /dev/null +++ b/console/index.html @@ -0,0 +1,13 @@ + + + + + + + OpenSandbox · Developer Console + + +
+ + + diff --git a/console/package-lock.json b/console/package-lock.json new file mode 100644 index 000000000..7fce48358 --- /dev/null +++ b/console/package-lock.json @@ -0,0 +1,4304 @@ +{ + "name": "opensandbox-console", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opensandbox-console", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.21", + "jsdom": "^25.0.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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", + "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/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.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/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "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/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "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==", + "dev": true, + "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/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "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==", + "dev": true, + "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.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "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==", + "dev": true, + "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.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "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", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "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.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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/console/package.json b/console/package.json new file mode 100644 index 000000000..a791bd678 --- /dev/null +++ b/console/package.json @@ -0,0 +1,32 @@ +{ + "name": "opensandbox-console", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:e2e": "playwright test --config tests/playwright.config.example.ts" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@playwright/test": "^1.55.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.21", + "jsdom": "^25.0.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } +} diff --git a/console/postcss.config.js b/console/postcss.config.js new file mode 100644 index 000000000..daedffd2b --- /dev/null +++ b/console/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/console/src/App.tsx b/console/src/App.tsx new file mode 100644 index 000000000..c9ad67377 --- /dev/null +++ b/console/src/App.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Link, NavLink, Route, Routes } from "react-router-dom"; +import { CreatePage } from "./pages/CreatePage"; +import { DetailPage } from "./pages/DetailPage"; +import { ListPage } from "./pages/ListPage"; + +function ConsoleNav() { + return ( + + ); +} + +export function App() { + const [dark, setDark] = useState(() => { + const saved = localStorage.getItem("os-console-theme"); + if (saved === "dark") return true; + if (saved === "light") return false; + return true; + }); + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + localStorage.setItem("os-console-theme", dark ? "dark" : "light"); + }, [dark]); + + return ( +
+
+
+ + + + + + OpenSandbox + +
+ + +
+
+
+
+ + } /> + } /> + } /> + +
+
+ ); +} diff --git a/console/src/api/client.ts b/console/src/api/client.ts new file mode 100644 index 000000000..6db314a5f --- /dev/null +++ b/console/src/api/client.ts @@ -0,0 +1,145 @@ +/** + * Lifecycle API client (user-auth path: no API key in the browser; proxy injects headers). + */ + +const API_PREFIX = (import.meta.env.VITE_API_PREFIX as string | undefined) ?? "/v1"; + +export type ApiErrorBody = { code: string; message: string }; + +export class ApiError extends Error { + constructor( + public status: number, + public body: ApiErrorBody, + ) { + super(body.message); + this.name = "ApiError"; + } +} + +export interface SandboxListItem { + id: string; + status: { state: string; reason?: string; message?: string }; + metadata?: Record; + image: { uri: string }; + expiresAt: string; + createdAt: string; + entrypoint: string[]; +} + +export interface ListResponse { + items: SandboxListItem[]; + pagination?: { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + hasNextPage: boolean; + }; +} + +async function parseJson(res: Response): Promise<{ data: unknown; text: string }> { + const text = await res.text(); + if (!text) { + return { data: null, text: "" }; + } + try { + return { data: JSON.parse(text) as unknown, text }; + } catch { + return { data: null, text }; + } +} + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_PREFIX}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + if (res.status === 204 || res.status === 205) { + return null as T; + } + const parsed = await parseJson(res); + const data = parsed.data as Record | null; + if (!res.ok) { + const code = typeof data?.code === "string" ? data.code : "HTTP_ERROR"; + let message = typeof data?.message === "string" ? data.message : ""; + if (!message && parsed.text && parsed.text.length < 240) { + message = parsed.text; + } + if (!message && res.status === 500) { + message = "The API returned an internal server error (500). Check the server and proxy logs for details."; + } + if (!message) { + message = res.statusText || `HTTP ${res.status}`; + } + throw new ApiError(res.status, { code, message }); + } + return data as T; +} + +export function listSandboxes(params: { state?: string; metadata?: string; page?: number; pageSize?: number }) { + const q = new URLSearchParams(); + if (params.state) { + q.set("state", params.state); + } + if (params.metadata) { + q.set("metadata", params.metadata); + } + if (params.page != null) { + q.set("page", String(params.page)); + } + if (params.pageSize != null) { + q.set("pageSize", String(params.pageSize)); + } + const qs = q.toString(); + return apiFetch(`/sandboxes${qs ? `?${qs}` : ""}`); +} + +export function getSandbox(id: string) { + return apiFetch(`/sandboxes/${encodeURIComponent(id)}`); +} + +export interface CreatePayload { + image: { uri: string }; + timeout: number; + resourceLimits: Record; + entrypoint: string[]; + env?: Record | null; + metadata?: Record; +} + +export function createSandbox(body: CreatePayload) { + return apiFetch<{ + id: string; + status: { state: string }; + metadata?: Record; + expiresAt: string; + }>("/sandboxes", { method: "POST", body: JSON.stringify(body) }); +} + +export function deleteSandbox(id: string) { + return apiFetch(`/sandboxes/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + +export function renewExpiration(id: string, expiresAt: string) { + return apiFetch<{ expiresAt: string }>(`/sandboxes/${encodeURIComponent(id)}/renew-expiration`, { + method: "POST", + body: JSON.stringify({ expiresAt }), + }); +} + +export function getEndpoint(id: string, port: number, useServerProxy = false) { + return apiFetch<{ endpoint: string }>( + `/sandboxes/${encodeURIComponent(id)}/endpoints/${port}?use_server_proxy=${useServerProxy ? "true" : "false"}`, + ); +} + +export function pauseSandbox(id: string) { + return apiFetch(`/sandboxes/${encodeURIComponent(id)}/pause`, { method: "POST" }); +} + +export function resumeSandbox(id: string) { + return apiFetch(`/sandboxes/${encodeURIComponent(id)}/resume`, { method: "POST" }); +} diff --git a/console/src/api/role.ts b/console/src/api/role.ts new file mode 100644 index 000000000..75fa062a4 --- /dev/null +++ b/console/src/api/role.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +const API_PREFIX = (import.meta.env.VITE_API_PREFIX as string | undefined) ?? "/v1"; + +/** + * Role hints for UI only (server enforces authorization). + * When using trusted headers, map X-OpenSandbox-Roles: operator | read_only. + */ +export function parseRoleFromEnv(): "operator" | "read_only" { + const r = (import.meta.env.VITE_UI_ROLE as string | undefined)?.toLowerCase() ?? "operator"; + if (r.includes("read")) { + return "read_only"; + } + return "operator"; +} + +export function canMutate(role: "operator" | "read_only"): boolean { + return role === "operator"; +} + +/** Fetch the caller's effective role from the server at runtime. */ +export async function fetchRole(): Promise<"operator" | "read_only"> { + try { + const res = await fetch(`${API_PREFIX}/auth/whoami`); + if (!res.ok) return parseRoleFromEnv(); + const data = (await res.json()) as { role?: string }; + const r = (data.role ?? "").toLowerCase(); + if (r.includes("read")) return "read_only"; + if (r === "operator") return "operator"; + } catch { + // network error or auth not configured — fall back to build-time env + } + return parseRoleFromEnv(); +} + +/** + * React hook that resolves the caller's role from the server. + * Initialises to the build-time env fallback so the UI is never blank + * while the request is in-flight. + */ +export function useRole(): "operator" | "read_only" { + const [role, setRole] = useState<"operator" | "read_only">(parseRoleFromEnv); + useEffect(() => { + fetchRole().then(setRole).catch(() => undefined); + }, []); + return role; +} diff --git a/console/src/components/AuthHint.tsx b/console/src/components/AuthHint.tsx new file mode 100644 index 000000000..70998c2a4 --- /dev/null +++ b/console/src/components/AuthHint.tsx @@ -0,0 +1,39 @@ +import { ApiError } from "../api/client"; + +export function AuthHint({ error }: { error: unknown }) { + if (error instanceof ApiError && (error.status === 401 || error.body.code === "MISSING_TRUSTED_IDENTITY")) { + return ( +
+ Authentication required. This console expects the API to accept trusted identity headers + (for example X-OpenSandbox-User and X-OpenSandbox-Roles) when{" "} + auth.mode = "api_key_and_user" in the server. In local dev, set + VITE_DEV_IDENTITY_USER and start the Vite dev server so the proxy can add these headers, or + use a reverse proxy in front of the server. +
+ ); + } + return null; +} + +export function ErrorBanner({ message, code }: { message: string; code?: string }) { + return ( +
+ {code ? ( + + {code} + {": "} + + ) : null} + {message} +
+ ); +} + +export function K8sPauseNote() { + return ( +

+ Pause and resume are not supported on every runtime (for example, some Kubernetes setups return{" "} + 501). +

+ ); +} diff --git a/console/src/main.tsx b/console/src/main.tsx new file mode 100644 index 000000000..aaa97ef1b --- /dev/null +++ b/console/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import "./tailwind.css"; + +const el = document.getElementById("root"); +const rawBase = import.meta.env.BASE_URL; +const routerBasename = rawBase === "/" ? undefined : rawBase.replace(/\/$/, ""); +if (el) { + createRoot(el).render( + + + + + , + ); +} diff --git a/console/src/pages/CreatePage.tsx b/console/src/pages/CreatePage.tsx new file mode 100644 index 000000000..462cea7b6 --- /dev/null +++ b/console/src/pages/CreatePage.tsx @@ -0,0 +1,171 @@ +import { type FormEvent, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { ApiError, createSandbox } from "../api/client"; +import { canMutate, useRole } from "../api/role"; +import { AuthHint, ErrorBanner } from "../components/AuthHint"; + +export function CreatePage() { + const nav = useNavigate(); + const [image, setImage] = useState("python:3.11"); + const [timeout, setTimeoutSec] = useState(3600); + const [cpu, setCpu] = useState("500m"); + const [mem, setMem] = useState("512Mi"); + const [entrypoint, setEntrypoint] = useState("python3, -c, print(1)"); + const [err, setErr] = useState(null); + const [submitting, setSubmitting] = useState(false); + const role = useRole(); + const mutate = canMutate(role); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setErr(null); + setSubmitting(true); + const ep = entrypoint + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ep.length < 1) { + setErr(new Error("Entrypoint must have at least one part.")); + setSubmitting(false); + return; + } + try { + const res = await createSandbox({ + image: { uri: image }, + timeout, + resourceLimits: { cpu, memory: mem }, + entrypoint: ep, + env: undefined, + }); + nav(`/sandboxes/${encodeURIComponent(res.id)}`); + } catch (ex) { + setErr(ex); + } finally { + setSubmitting(false); + } + } + + if (!mutate) { + return ( +
+
+ +
+
+ Read-only role. You cannot create sandboxes. Ask an operator to change your + X-OpenSandbox-Roles to operator (or set VITE_UI_ROLE=operator{" "} + for the dev UI hint). +
+
+ ); + } + + return ( +
+
+ +
+

Create sandbox

+

+ Reserved metadata for owner/team scope is injected on the server for user-authenticated requests. Do not expect + to set access.owner from the browser. +

+ + {err && !(err instanceof ApiError) ? : null} + {err instanceof ApiError ? : null} + +
+
+ + setImage(e.target.value)} + required + /> +
+
+ + setTimeoutSec(Number(e.target.value))} + required + /> +
+
+
+ + setCpu(e.target.value)} + /> +
+
+ + setMem(e.target.value)} + /> +
+
+
+ + setEntrypoint(e.target.value)} + required + /> +
+ +
+
+ ); +} diff --git a/console/src/pages/DetailPage.tsx b/console/src/pages/DetailPage.tsx new file mode 100644 index 000000000..c21f7fdba --- /dev/null +++ b/console/src/pages/DetailPage.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { + ApiError, + deleteSandbox, + getEndpoint, + getSandbox, + pauseSandbox, + renewExpiration, + resumeSandbox, + type SandboxListItem, +} from "../api/client"; +import { canMutate, useRole } from "../api/role"; +import { AuthHint, ErrorBanner, K8sPauseNote } from "../components/AuthHint"; + +export function DetailPage() { + const { id: rawId } = useParams(); + const id = rawId ? decodeURIComponent(rawId) : ""; + const nav = useNavigate(); + const [box, setBox] = useState(null); + const [err, setErr] = useState(null); + const [endpoint, setEndpoint] = useState(null); + const [port, setPort] = useState("8080"); + const [renewAt, setRenewAt] = useState(""); + const [busy, setBusy] = useState(false); + const role = useRole(); + const mutate = canMutate(role); + + const load = useCallback(async () => { + if (!id) { + return; + } + setErr(null); + try { + const s = await getSandbox(id); + setBox(s); + } catch (e) { + setErr(e); + } + }, [id]); + + useEffect(() => { + void load(); + }, [load]); + + async function onFetchEndpoint() { + if (!id) { + return; + } + setErr(null); + setEndpoint(null); + const p = Number(port); + if (!Number.isFinite(p) || p < 1 || p > 65535) { + setErr(new Error("Invalid port")); + return; + } + try { + const e = await getEndpoint(id, p, false); + setEndpoint(e.endpoint); + } catch (e) { + setErr(e); + } + } + + async function onRenew() { + if (!id || !renewAt) { + return; + } + setBusy(true); + setErr(null); + try { + await renewExpiration(id, renewAt); + await load(); + } catch (e) { + setErr(e); + } finally { + setBusy(false); + } + } + + async function onDelete() { + if (!id || !window.confirm("Delete this sandbox?")) { + return; + } + setBusy(true); + setErr(null); + try { + await deleteSandbox(id); + nav("/"); + } catch (e) { + setErr(e); + } finally { + setBusy(false); + } + } + + async function onPause() { + if (!id) { + return; + } + setBusy(true); + setErr(null); + try { + await pauseSandbox(id); + await load(); + } catch (e) { + setErr(e); + } finally { + setBusy(false); + } + } + + async function onResume() { + if (!id) { + return; + } + setBusy(true); + setErr(null); + try { + await resumeSandbox(id); + await load(); + } catch (e) { + setErr(e); + } finally { + setBusy(false); + } + } + + if (!id) { + return

Missing id.

; + } + + return ( +
+
+ +
+ + {err && !(err instanceof ApiError) ? : null} + {err instanceof ApiError && err.status !== 401 ? ( + + ) : null} + + {box && ( +
+

+ {box.id} + + {box.status.state} + +

+

Image: {box.image?.uri}

+

Expires: {box.expiresAt}

+ {box.metadata && Object.keys(box.metadata).length > 0 && ( +
+

+ Metadata +

+
{JSON.stringify(box.metadata, null, 2)}
+
+ )} +

+ entrypoint: {JSON.stringify(box.entrypoint)} +

+
+ )} + +
+

Get endpoint

+

Resolves a published port to a reachable host (per server ingress settings).

+
+ + setPort(e.target.value)} + /> + +
+ {endpoint && ( +

+ {endpoint} +

+ )} +
+ + {mutate && ( +
+

Renew expiration

+
+ + setRenewAt(e.target.value)} + placeholder="2030-01-01T12:00:00Z" + /> +
+ +
+ )} + + {mutate && ( +
+

Lifecycle

+ +

+ + + +

+
+ )} + + {!mutate && ( +
+ Read-only. Your UI role is read_only; create, renew, delete, and pause are + hidden. The server is always authoritative. +
+ )} +
+ ); +} diff --git a/console/src/pages/ListPage.tsx b/console/src/pages/ListPage.tsx new file mode 100644 index 000000000..2e44dfe74 --- /dev/null +++ b/console/src/pages/ListPage.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { ApiError, listSandboxes, type ListResponse } from "../api/client"; +import { AuthHint, ErrorBanner } from "../components/AuthHint"; +import { useRole } from "../api/role"; + +const STATES = ["", "Running", "Pending", "Paused", "Stopping", "Terminated", "Failed"]; + +export function ListPage() { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const [stateFilter, setStateFilter] = useState(""); + const [metaQuery, setMetaQuery] = useState(""); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const role = useRole(); + + const load = useCallback(async () => { + setLoading(true); + setErr(null); + try { + const r = await listSandboxes({ + state: stateFilter || undefined, + metadata: metaQuery || undefined, + page, + pageSize: 20, + }); + setData(r); + } catch (e) { + setErr(e); + } finally { + setLoading(false); + } + }, [stateFilter, metaQuery, page]); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+
+
+

+ OpenSandbox Console +

+ +

+ Lifecycle operations for AI sandboxes. +

+

+ List, inspect, renew, and manage sandbox instances. +

+
+ + + Create sandbox + +
+
+
+

+ Sandboxes + + {data?.items?.length ?? 0} on page + +

+

+ UI role: {role} (server enforces real role) +

+

+ Current page: {page} {loading ? "· Loading..." : ""} +

+
+
+ + + {err && !(err instanceof ApiError) ? : null} + {err instanceof ApiError && err.status !== 401 ? ( + + ) : null} + +
+
+
+ + +
+
+ + { + setPage(1); + setMetaQuery(e.target.value); + }} + placeholder="e.g. project=demo" + /> +
+
+

+ Server-side owner/team scope (reserved metadata keys) applies automatically for console users; API key clients + are unchanged. +

+
+ + Page {page} + + +
+
+ + {loading && !data ?

Loading…

: null} + {data && ( +
+ + + + + + + + + + + {data.items?.map((s) => ( + + + + + + + ))} + +
IDStateImageExpires
+ {s.id} + + + {s.status.state} + + + {s.image?.uri} + + {s.expiresAt} +
+ {data.items?.length === 0 &&

No sandboxes match the filters.

} +
+ )} +
+ ); +} diff --git a/console/src/tailwind.css b/console/src/tailwind.css new file mode 100644 index 000000000..04b35af2a --- /dev/null +++ b/console/src/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/console/src/vite-env.d.ts b/console/src/vite-env.d.ts new file mode 100644 index 000000000..e53817dee --- /dev/null +++ b/console/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_PREFIX: string; + readonly VITE_SANDBOX_HELP_RUNTIME_PAUSE: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/console/tailwind.config.js b/console/tailwind.config.js new file mode 100644 index 000000000..6c318d9fd --- /dev/null +++ b/console/tailwind.config.js @@ -0,0 +1,14 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: "class", + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + "os-brand": "#2563eb", + "os-brand-dark": "#1d4ed8", + }, + }, + }, + plugins: [], +}; diff --git a/console/tests/e2e/console.integration.spec.ts b/console/tests/e2e/console.integration.spec.ts new file mode 100644 index 000000000..b6c4ae795 --- /dev/null +++ b/console/tests/e2e/console.integration.spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from "@playwright/test"; +import { copyFileSync, mkdirSync } from "node:fs"; + +const shotsDir = "output/playwright"; +const docsShotsDir = "../docs/public/images/console"; + +test.beforeAll(() => { + mkdirSync(shotsDir, { recursive: true }); + mkdirSync(docsShotsDir, { recursive: true }); +}); + +test.beforeEach(async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + + await page.route("**/v1/sandboxes?**", async (route) => { + const url = route.request().url(); + if (url.includes("state=Running")) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: "sbx-running", + image: { uri: "python:3.11" }, + status: { state: "Running" }, + metadata: { "access.owner": "alice", project: "docs-demo" }, + entrypoint: ["python", "-V"], + expiresAt: "2030-01-01T00:00:00Z", + createdAt: "2029-12-31T00:00:00Z", + }, + ], + pagination: { page: 1, pageSize: 20, totalItems: 1, totalPages: 1, hasNextPage: false }, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: "sbx-001", + image: { uri: "python:3.11" }, + status: { state: "Running" }, + metadata: { "access.owner": "alice", project: "docs-demo" }, + entrypoint: ["python", "-V"], + expiresAt: "2030-01-01T00:00:00Z", + createdAt: "2029-12-31T00:00:00Z", + }, + { + id: "sbx-002", + image: { uri: "node:20" }, + status: { state: "Failed" }, + metadata: { "access.owner": "alice", project: "docs-demo" }, + entrypoint: ["node", "-v"], + expiresAt: "2030-01-01T00:00:00Z", + createdAt: "2029-12-31T00:00:00Z", + }, + ], + pagination: { page: 1, pageSize: 20, totalItems: 2, totalPages: 1, hasNextPage: false }, + }), + }); + }); + + await page.route("**/v1/sandboxes/sbx-001", async (route) => { + if (route.request().method() === "DELETE") { + await route.fulfill({ status: 204, body: "" }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "sbx-001", + image: { uri: "python:3.11" }, + status: { state: "Running" }, + metadata: { "access.owner": "alice", project: "docs-demo" }, + entrypoint: ["python", "-V"], + expiresAt: "2030-01-01T00:00:00Z", + createdAt: "2029-12-31T00:00:00Z", + }), + }); + }); + + await page.route("**/v1/sandboxes/sbx-001/endpoints/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ endpoint: "sandbox.example.com/sbx-001/8080" }), + }); + }); + + await page.route("**/v1/sandboxes/sbx-001/renew-expiration", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ expiresAt: "2030-01-02T00:00:00Z" }), + }); + }); + + await page.route("**/v1/sandboxes/sbx-001/pause", async (route) => { + await route.fulfill({ status: 202, body: "" }); + }); + await page.route("**/v1/sandboxes/sbx-001/resume", async (route) => { + await route.fulfill({ status: 202, body: "" }); + }); + + await page.route("**/v1/sandboxes", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + id: "sbx-created", + status: { state: "Pending" }, + metadata: { "access.owner": "alice" }, + expiresAt: "2030-01-01T00:00:00Z", + createdAt: "2029-12-31T00:00:00Z", + entrypoint: ["python", "-V"], + }), + }); + return; + } + await route.fallback(); + }); + + await page.route("**/v1/sandboxes/sbx-created", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "sbx-created", + image: { uri: "python:3.11" }, + status: { state: "Pending" }, + metadata: { "access.owner": "alice" }, + entrypoint: ["python", "-V"], + expiresAt: "2030-01-01T00:00:00Z", + createdAt: "2029-12-31T00:00:00Z", + }), + }); + }); +}); + +function saveScreenshot(tempPath: string, fileName: string) { + const finalPath = `${shotsDir}/${fileName}`; + const docsPath = `${docsShotsDir}/${fileName}`; + copyFileSync(tempPath, finalPath); + copyFileSync(tempPath, docsPath); +} + +test("list/detail/create lifecycle flows render and work", async ({ page }, testInfo) => { + await page.goto("/console/"); + await expect(page.getByRole("heading", { name: "OpenSandbox Console" })).toBeVisible(); + await expect(page.getByRole("link", { name: "sbx-001" })).toBeVisible(); + const listShot = testInfo.outputPath("console-list.png"); + await page.screenshot({ path: listShot, fullPage: true }); + saveScreenshot(listShot, "console-list.png"); + + await page.getByRole("link", { name: "sbx-001" }).click(); + await expect(page.getByRole("heading", { name: "sbx-001" })).toBeVisible(); + await page.fill('input[placeholder="2030-01-01T12:00:00Z"]', "2030-01-02T00:00:00Z"); + await page.getByRole("button", { name: "Renew" }).click(); + await page.fill("input", "8080"); + await page.getByRole("button", { name: "Get endpoint" }).click(); + await expect(page.getByText("sandbox.example.com/sbx-001/8080")).toBeVisible(); + const detailShot = testInfo.outputPath("console-detail.png"); + await page.screenshot({ path: detailShot, fullPage: true }); + saveScreenshot(detailShot, "console-detail.png"); + + await page.getByRole("link", { name: "Create" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByRole("heading", { name: "sbx-created" })).toBeVisible(); + const createShot = testInfo.outputPath("console-create.png"); + await page.screenshot({ path: createShot, fullPage: true }); + saveScreenshot(createShot, "console-create.png"); +}); + +test("auth misconfiguration banner is shown on 401 missing trusted identity", async ({ page }, testInfo) => { + await page.unroute("**/v1/sandboxes?**"); + await page.route("**/v1/sandboxes?**", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ code: "MISSING_TRUSTED_IDENTITY", message: "missing trusted headers" }), + }); + }); + + await page.goto("/console/"); + await expect(page.getByText("Authentication required.")).toBeVisible(); + const authShot = testInfo.outputPath("console-auth-error.png"); + await page.screenshot({ path: authShot, fullPage: true }); + saveScreenshot(authShot, "console-auth-error.png"); +}); diff --git a/console/tests/playwright.config.example.ts b/console/tests/playwright.config.example.ts new file mode 100644 index 000000000..e39e85884 --- /dev/null +++ b/console/tests/playwright.config.example.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Copy to `playwright.config.ts` at the console package root if you need local overrides. + * CI and default `npm run test:e2e` use this file via `--config`. + */ +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]], + use: { + baseURL: "http://127.0.0.1:4173", + colorScheme: "dark", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173/console/", + cwd: "..", + reuseExistingServer: true, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/console/tests/unit/client.test.ts b/console/tests/unit/client.test.ts new file mode 100644 index 000000000..32622c15e --- /dev/null +++ b/console/tests/unit/client.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { ApiError, apiFetch } from "../../src/api/client"; + +describe("apiFetch", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ items: [], pagination: { page: 1, pageSize: 20, total: 0 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("parses json on success", async () => { + const r = await apiFetch<{ + items: unknown[]; + pagination: { page: number; pageSize: number; total: number }; + }>("/sandboxes"); + expect(r.items).toEqual([]); + }); + + it("throws ApiError on 401 with body", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ code: "MISSING_TRUSTED_IDENTITY", message: "nope" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ), + ), + ); + await expect(apiFetch("/x")).rejects.toMatchObject({ status: 401, body: { code: "MISSING_TRUSTED_IDENTITY" } }); + }); + + it("returns null for 204", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))); + const r = await apiFetch("/sandboxes/abc"); + expect(r).toBeNull(); + }); + + it("shows helpful message for 500 without json body", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response("Internal Server Error", { status: 500 })))); + await expect(apiFetch("/x")).rejects.toMatchObject({ + status: 500, + body: { code: "HTTP_ERROR", message: "Internal Server Error" }, + }); + }); +}); + +describe("ApiError", () => { + it("exposes code and message", () => { + const e = new ApiError(403, { code: "INSUFFICIENT_ROLE", message: "no" }); + expect(e.status).toBe(403); + expect(e.body.code).toBe("INSUFFICIENT_ROLE"); + }); +}); diff --git a/console/tests/unit/role.test.ts b/console/tests/unit/role.test.ts new file mode 100644 index 000000000..8d2b58ae8 --- /dev/null +++ b/console/tests/unit/role.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { canMutate, parseRoleFromEnv } from "../../src/api/role"; + +describe("parseRoleFromEnv", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("defaults to operator", () => { + vi.stubEnv("VITE_UI_ROLE", undefined); + expect(parseRoleFromEnv()).toBe("operator"); + }); + it("detects read_only", () => { + vi.stubEnv("VITE_UI_ROLE", "read_only"); + expect(parseRoleFromEnv()).toBe("read_only"); + }); +}); + +describe("canMutate", () => { + it("operator can mutate", () => { + expect(canMutate("operator")).toBe(true); + }); + it("read_only cannot", () => { + expect(canMutate("read_only")).toBe(false); + }); +}); diff --git a/console/tests/vitest-setup.ts b/console/tests/vitest-setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/console/tests/vitest-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/console/tsconfig.json b/console/tsconfig.json new file mode 100644 index 000000000..eaa417131 --- /dev/null +++ b/console/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/console/tsconfig.node.json b/console/tsconfig.node.json new file mode 100644 index 000000000..0d7e4b872 --- /dev/null +++ b/console/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/console/vite.config.ts b/console/vite.config.ts new file mode 100644 index 000000000..833e16b09 --- /dev/null +++ b/console/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const user = env.VITE_DEV_IDENTITY_USER ?? ""; + const team = env.VITE_DEV_IDENTITY_TEAM ?? ""; + const roles = env.VITE_DEV_IDENTITY_ROLES ?? "operator"; + const proxy: Record = { + [env.VITE_API_PREFIX ?? env.VITE_API_BASE_PATH ?? "/v1"]: { + target: env.VITE_API_PROXY_TARGET ?? "http://127.0.0.1:8080", + changeOrigin: true, + configure(p) { + p.on("proxyReq", (proxyReq) => { + if (user) { + proxyReq.setHeader("X-OpenSandbox-User", user); + if (team) { + proxyReq.setHeader("X-OpenSandbox-Team", team); + } + proxyReq.setHeader("X-OpenSandbox-Roles", roles); + } + }); + }, + }, + }; + return { + base: env.VITE_BASE ?? "/console/", + plugins: [react()], + server: { proxy }, + }; +}); diff --git a/console/vitest.config.ts b/console/vitest.config.ts new file mode 100644 index 000000000..f104551e1 --- /dev/null +++ b/console/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./tests/vitest-setup.ts"], + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 856b8990c..688e91e92 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,48 +1,49 @@ -import { defineConfig } from "vitepress"; -import { loadManifest } from "./scripts/docs-manifest.mjs"; - -const manifest = loadManifest(); -const docsBase = process.env.DOCS_BASE || "/"; - -export default defineConfig({ - title: "OpenSandbox", - description: "OpenSandbox documentation site for users and developers", - head: [["link", { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }]], - cleanUrls: true, - lastUpdated: true, - base: docsBase, - ignoreDeadLinks: [/^https?:\/\/localhost/, /\/README$/, /\/index$/, "./contributing"], - srcExclude: ["node_modules/**", "README_zh.md", "RELEASE_NOTE_TEMPLATE.md"], - rewrites: manifest.rewrites, - themeConfig: { - logo: "/assets/logo.svg", - search: { - provider: "local", - }, - socialLinks: [{ icon: "github", link: "https://github.com/alibaba/OpenSandbox" }], - nav: manifest.nav.en, - sidebar: { - ...manifest.sidebar.en, - ...manifest.sidebar.zh, - }, - outline: { - level: [2, 3], - }, - }, - locales: { - root: { - label: "English", - lang: "en-US", - themeConfig: { - nav: manifest.nav.en, - }, - }, - zh: { - label: "简体中文", - lang: "zh-CN", - themeConfig: { - nav: manifest.nav.zh, - }, - }, - }, -}); +import { defineConfig } from "vitepress"; +import { loadManifest } from "./scripts/docs-manifest.mjs"; + +const manifest = loadManifest(); +const docsBase = process.env.DOCS_BASE || "/"; + +export default defineConfig({ + title: "OpenSandbox", + description: "OpenSandbox documentation site for users and developers", + appearance: "dark", + head: [["link", { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }]], + cleanUrls: true, + lastUpdated: true, + base: docsBase, + ignoreDeadLinks: [/^https?:\/\/localhost/, /\/README$/, /\/index$/, "./contributing"], + srcExclude: ["node_modules/**", "README_zh.md", "RELEASE_NOTE_TEMPLATE.md"], + rewrites: manifest.rewrites, + themeConfig: { + logo: "/assets/logo.svg", + search: { + provider: "local", + }, + socialLinks: [{ icon: "github", link: "https://github.com/alibaba/OpenSandbox" }], + nav: manifest.nav.en, + sidebar: { + ...manifest.sidebar.en, + ...manifest.sidebar.zh, + }, + outline: { + level: [2, 3], + }, + }, + locales: { + root: { + label: "English", + lang: "en-US", + themeConfig: { + nav: manifest.nav.en, + }, + }, + zh: { + label: "简体中文", + lang: "zh-CN", + themeConfig: { + nav: manifest.nav.zh, + }, + }, + }, +}); diff --git a/docs/public/images/console/console-auth-error.png b/docs/public/images/console/console-auth-error.png new file mode 100644 index 000000000..67727dfbe Binary files /dev/null and b/docs/public/images/console/console-auth-error.png differ diff --git a/docs/public/images/console/console-create.png b/docs/public/images/console/console-create.png new file mode 100644 index 000000000..582595c16 Binary files /dev/null and b/docs/public/images/console/console-create.png differ diff --git a/docs/public/images/console/console-detail.png b/docs/public/images/console/console-detail.png new file mode 100644 index 000000000..bc15b256c Binary files /dev/null and b/docs/public/images/console/console-detail.png differ diff --git a/docs/public/images/console/console-list.png b/docs/public/images/console/console-list.png new file mode 100644 index 000000000..d9dd7a26e Binary files /dev/null and b/docs/public/images/console/console-list.png differ diff --git a/server/opensandbox_server/api/auth.py b/server/opensandbox_server/api/auth.py new file mode 100644 index 000000000..942d87401 --- /dev/null +++ b/server/opensandbox_server/api/auth.py @@ -0,0 +1,53 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Auth introspection endpoint — returns the caller's resolved role so the +developer console can derive per-user UI permissions at runtime. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from pydantic import BaseModel + +from opensandbox_server.api.lifecycle_helpers import get_principal + +router = APIRouter(tags=["Auth"]) + + +class WhoAmIResponse(BaseModel): + role: str + subject: str + + +@router.get( + "/auth/whoami", + response_model=WhoAmIResponse, + responses={ + 200: {"description": "Caller identity and effective role"}, + 401: {"description": "Authentication credentials are missing or invalid"}, + }, +) +async def whoami(http_request: Request) -> WhoAmIResponse: + """ + Return the authenticated caller's effective role and subject. + + Used by the developer console to derive role-aware UI at runtime instead of + relying on the build-time VITE_UI_ROLE environment variable. + """ + principal = get_principal(http_request) + if principal is None: + return WhoAmIResponse(role="service_admin", subject="anonymous") + return WhoAmIResponse(role=principal.role, subject=principal.subject) diff --git a/server/opensandbox_server/api/lifecycle.py b/server/opensandbox_server/api/lifecycle.py index 39bbc62b8..47c1885f5 100644 --- a/server/opensandbox_server/api/lifecycle.py +++ b/server/opensandbox_server/api/lifecycle.py @@ -1,561 +1,1120 @@ -# Copyright 2025 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -API routes for OpenSandbox Lifecycle API. - -This module defines FastAPI routes that map to the OpenAPI specification endpoints. -All business logic is delegated to the service layer that backs each operation. -""" - -from typing import List, Optional - -from fastapi import APIRouter, Body, Header, Query, Request, status -from fastapi.responses import Response - -from opensandbox_server.extensions import validate_extensions -from opensandbox_server.config import get_config -from opensandbox_server.api.schema import ( - CreateSnapshotRequest, - CreateSandboxRequest, - CreateSandboxResponse, - Endpoint, - ErrorResponse, - ListSnapshotsRequest, - ListSnapshotsResponse, - ListSandboxesRequest, - ListSandboxesResponse, - PaginationRequest, - PatchSandboxMetadataRequest, - RenewSandboxExpirationRequest, - RenewSandboxExpirationResponse, - Sandbox, - SandboxFilter, - Snapshot, - SnapshotFilter, -) -from opensandbox_server.services.factory import create_sandbox_service -from opensandbox_server.services.snapshot_service import create_snapshot_service - -# Initialize router -router = APIRouter(tags=["Sandboxes"]) - -# Initialize service based on configuration from config.toml (defaults to docker) -sandbox_service = create_sandbox_service() -snapshot_service = create_snapshot_service(sandbox_service) - - -# ============================================================================ -# Sandbox CRUD Operations -# ============================================================================ - -@router.post( - "/sandboxes", - response_model=CreateSandboxResponse, - response_model_exclude_none=True, - status_code=status.HTTP_202_ACCEPTED, - responses={ - 202: {"description": "Sandbox creation accepted for asynchronous provisioning"}, - 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -async def create_sandbox( - request: CreateSandboxRequest, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> CreateSandboxResponse: - """ - Create a sandbox from a container image. - - Creates a new sandbox from a container image with optional resource limits, - environment variables, and metadata. Sandboxes are provisioned directly from - the specified image without requiring a pre-created template. - - Args: - request: Sandbox creation request - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - CreateSandboxResponse: Accepted sandbox creation request - - Raises: - HTTPException: If sandbox creation scheduling fails - """ - validate_extensions(request.extensions) - return await sandbox_service.create_sandbox(request) - - -# Search endpoint -@router.get( - "/sandboxes", - response_model=ListSandboxesResponse, - response_model_exclude_none=True, - responses={ - 200: {"description": "Paginated collection of sandboxes"}, - 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def list_sandboxes( - state: Optional[List[str]] = Query(None, description="Filter by lifecycle state. Pass multiple times for OR logic."), - metadata: Optional[str] = Query(None, description="Arbitrary metadata key-value pairs for filtering (URL encoded)."), - page: int = Query(1, ge=1, description="Page number for pagination"), - page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"), - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> ListSandboxesResponse: - """ - List sandboxes with optional filtering and pagination. - - List all sandboxes with optional filtering and pagination using query parameters. - All filter conditions use AND logic. Multiple `state` parameters use OR logic within states. - - Args: - state: Filter by lifecycle state. - metadata: Arbitrary metadata key-value pairs for filtering. - page: Page number for pagination. - page_size: Number of items per page. - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - ListSandboxesResponse: Paginated list of sandboxes - """ - # Parse metadata query string into dictionary - metadata_dict = {} - if metadata: - from urllib.parse import parse_qsl - try: - # Parse query string format: key=value&key2=value2 - # strict_parsing=True rejects malformed segments like "a=1&broken" - parsed = parse_qsl(metadata, keep_blank_values=True, strict_parsing=True) - metadata_dict = dict(parsed) - except Exception as e: - from fastapi import HTTPException - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "INVALID_METADATA_FORMAT", "message": f"Invalid metadata format: {str(e)}"} - ) - - # Construct request object - request = ListSandboxesRequest( - filter=SandboxFilter(state=state, metadata=metadata_dict if metadata_dict else None), - pagination=PaginationRequest(page=page, pageSize=page_size) - ) - - import logging - logger = logging.getLogger(__name__) - logger.info("ListSandboxes: %s", request.filter) - - # Delegate to the service layer for filtering and pagination - return sandbox_service.list_sandboxes(request) - - -@router.get( - "/sandboxes/{sandbox_id}", - response_model=Sandbox, - response_model_exclude_none=True, - responses={ - 200: {"description": "Sandbox current state and metadata"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def get_sandbox( - sandbox_id: str, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Sandbox: - """ - Fetch a sandbox by id. - - Returns the complete sandbox information including image specification, - status, metadata, and timestamps. - - Args: - sandbox_id: Unique sandbox identifier - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - Sandbox: Complete sandbox information - - Raises: - HTTPException: If sandbox not found or access denied - """ - # Delegate to the service layer for sandbox lookup - return sandbox_service.get_sandbox(sandbox_id) - - -@router.patch( - "/sandboxes/{sandbox_id}/metadata", - response_model=Sandbox, - response_model_exclude_none=True, - responses={ - 200: {"description": "Metadata patched successfully. Returns the complete sandbox with updated metadata."}, - 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def patch_sandbox_metadata( - sandbox_id: str, - patch: PatchSandboxMetadataRequest = Body(...), - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Sandbox: - """ - Patch sandbox metadata via JSON Merge Patch (RFC 7396). - Non-null adds/replaces, null deletes, absent keeps. - Read-modify-write without optimistic locking — concurrent PATCH may drop updates. - """ - return sandbox_service.patch_sandbox_metadata(sandbox_id, patch) - - -@router.delete( - "/sandboxes/{sandbox_id}", - status_code=status.HTTP_204_NO_CONTENT, - responses={ - 204: {"description": "Sandbox successfully deleted"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def delete_sandbox( - sandbox_id: str, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Response: - """ - Delete a sandbox. - - Terminates sandbox execution. The sandbox will transition through Stopping state to Terminated. - - Args: - sandbox_id: Unique sandbox identifier - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - Response: 204 No Content - - Raises: - HTTPException: If sandbox not found or deletion fails - """ - # Delegate to the service layer for deletion - sandbox_service.delete_sandbox(sandbox_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -# ============================================================================ -# Sandbox Lifecycle Operations -# ============================================================================ - -@router.post( - "/sandboxes/{sandbox_id}/pause", - status_code=status.HTTP_202_ACCEPTED, - responses={ - 202: {"description": "Pause operation accepted"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def pause_sandbox( - sandbox_id: str, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Response: - """ - Pause execution while retaining state. - - Pauses a running sandbox while preserving its state. - Poll GET /sandboxes/{sandboxId} to track state transition through Pausing and eventually Paused. - - Args: - sandbox_id: Unique sandbox identifier - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - Response: 202 Accepted - - Raises: - HTTPException: If sandbox not found or cannot be paused - """ - # Delegate to the service layer for pause orchestration - sandbox_service.pause_sandbox(sandbox_id) - return Response(status_code=status.HTTP_202_ACCEPTED) - - -@router.post( - "/sandboxes/{sandbox_id}/resume", - status_code=status.HTTP_202_ACCEPTED, - responses={ - 202: {"description": "Resume operation accepted"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def resume_sandbox( - sandbox_id: str, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Response: - """ - Resume a paused sandbox. - - Resumes execution of a paused sandbox. - Poll GET /sandboxes/{sandboxId} to track state transition through Resuming and eventually Running. - - Args: - sandbox_id: Unique sandbox identifier - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - Response: 202 Accepted - - Raises: - HTTPException: If sandbox not found or cannot be resumed - """ - # Delegate to the service layer for resume orchestration - sandbox_service.resume_sandbox(sandbox_id) - return Response(status_code=status.HTTP_202_ACCEPTED) - - -@router.post( - "/sandboxes/{sandbox_id}/renew-expiration", - response_model=RenewSandboxExpirationResponse, - response_model_exclude_none=True, - responses={ - 200: {"description": "Sandbox expiration updated successfully"}, - 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def renew_sandbox_expiration( - sandbox_id: str, - request: RenewSandboxExpirationRequest, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> RenewSandboxExpirationResponse: - """ - Renew sandbox expiration. - - Renews the absolute expiration time of a sandbox. - The new expiration time must be in the future and after the current expiresAt time. - - Args: - sandbox_id: Unique sandbox identifier - request: Renewal request with new expiration time - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - RenewSandboxExpirationResponse: Updated expiration time - - Raises: - HTTPException: If sandbox not found or renewal fails - """ - # Delegate to the service layer for expiration updates - return sandbox_service.renew_expiration(sandbox_id, request) - - -# ============================================================================ -# Snapshot Operations -# ============================================================================ - -@router.post( - "/sandboxes/{sandbox_id}/snapshots", - tags=["Snapshots"], - response_model=Snapshot, - response_model_exclude_none=True, - status_code=status.HTTP_202_ACCEPTED, - responses={ - 202: {"description": "Snapshot creation accepted"}, - 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, - 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def create_snapshot( - sandbox_id: str, - response: Response, - request: Optional[CreateSnapshotRequest] = None, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Snapshot: - """ - Create a persistent point-in-time snapshot from a sandbox. - """ - create_request = request or CreateSnapshotRequest() - snapshot = snapshot_service.create_snapshot(sandbox_id, create_request) - response.headers["Location"] = f"/v1/snapshots/{snapshot.id}" - return snapshot - - -@router.get( - "/snapshots", - tags=["Snapshots"], - response_model=ListSnapshotsResponse, - response_model_exclude_none=True, - responses={ - 200: {"description": "Paginated collection of snapshots"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def list_snapshots( - sandbox_id: Optional[str] = Query(None, alias="sandboxId", description="Filter snapshots by source sandbox identifier"), - state: Optional[List[str]] = Query(None, description="Filter by snapshot lifecycle state. Pass multiple times for OR logic."), - page: int = Query(1, ge=1, description="Page number for pagination"), - page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"), - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> ListSnapshotsResponse: - """ - List snapshots with optional filtering and pagination. - """ - request = ListSnapshotsRequest( - filter=SnapshotFilter(sandboxId=sandbox_id, state=state), - pagination=PaginationRequest(page=page, pageSize=page_size), - ) - return snapshot_service.list_snapshots(request) - - -@router.get( - "/snapshots/{snapshot_id}", - tags=["Snapshots"], - response_model=Snapshot, - response_model_exclude_none=True, - responses={ - 200: {"description": "Snapshot current state and metadata"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def get_snapshot( - snapshot_id: str, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Snapshot: - """ - Fetch a snapshot by id. - """ - return snapshot_service.get_snapshot(snapshot_id) - - -@router.delete( - "/snapshots/{snapshot_id}", - tags=["Snapshots"], - status_code=status.HTTP_204_NO_CONTENT, - responses={ - 204: {"description": "Snapshot successfully deleted"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 409: {"model": ErrorResponse, "description": "The snapshot is not in a deletable state or is still in use"}, - 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def delete_snapshot( - snapshot_id: str, - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Response: - """ - Delete a snapshot by id. - """ - snapshot_service.delete_snapshot(snapshot_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -# ============================================================================ -# Sandbox Endpoints -# ============================================================================ - -@router.get( - "/sandboxes/{sandbox_id}/endpoints/{port}", - response_model=Endpoint, - response_model_exclude_none=True, - responses={ - 200: {"description": "Endpoint retrieved successfully"}, - 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, - 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, - 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, - 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, - }, -) -def get_sandbox_endpoint( - request: Request, - sandbox_id: str, - port: int, - use_server_proxy: bool = Query(False, description="Whether to return a server-proxied URL"), - expires: Optional[int] = Query(None, description="Request a signed route token with this Unix epoch second expiration. Requires ingress gateway with secure_access configured."), - x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), -) -> Endpoint: - """ - Get sandbox access endpoint. - - Returns the public access endpoint URL for accessing a service running on a specific port - within the sandbox. The service must be listening on the specified port inside the sandbox - for the endpoint to be available. - - When the ``expires`` query parameter is provided, the endpoint is wrapped in a - cryptographically signed route token (OSEP-0011) instead of returning a plain URL. - This requires the ingress gateway to be configured with secure_access signing keys. - - Args: - request: FastAPI request object - sandbox_id: Unique sandbox identifier - port: Port number where the service is listening inside the sandbox (1-65535) - use_server_proxy: Whether to return a server-proxied URL - expires: Unix epoch seconds for signed route token expiration. Must be a - non-negative uint64 value. When omitted or invalid, a plain (unsigned) - endpoint is returned. - x_request_id: Unique request identifier for tracing (optional; server generates if omitted). - - Returns: - Endpoint: Public endpoint URL - - Raises: - HTTPException: If sandbox not found, endpoint not available, or signed - routes are not supported by the runtime/configuration (400). - """ - # Delegate to the service layer for endpoint resolution - endpoint = sandbox_service.get_endpoint(sandbox_id, port, expires=expires) - - if use_server_proxy: - # Prefer configured external address when available. - base_url = str(request.base_url).rstrip("/") - eip = (get_config().server.eip or "").strip().rstrip("/") - if eip: - base_url = eip - base_url = base_url.replace("https://", "").replace("http://", "") - endpoint.endpoint = f"{base_url}/sandboxes/{sandbox_id}/proxy/{port}" - - return endpoint +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +API routes for OpenSandbox Lifecycle API. + +This module defines FastAPI routes that map to the OpenAPI specification endpoints. +All business logic is delegated to the service layer that backs each operation. +""" + +import asyncio +from typing import List, Optional + +from fastapi import APIRouter, Body, Header, HTTPException, Query, Request, status +from fastapi.responses import Response + +from opensandbox_server.extensions import validate_extensions +from opensandbox_server.config import get_config +from opensandbox_server.api.schema import ( + CreateSnapshotRequest, + CreateSandboxRequest, + CreateSandboxResponse, + Endpoint, + ErrorResponse, + ListSnapshotsRequest, + ListSnapshotsResponse, + ListSandboxesRequest, + ListSandboxesResponse, + PaginationRequest, + PatchSandboxMetadataRequest, + RenewSandboxExpirationRequest, + RenewSandboxExpirationResponse, + Sandbox, + SandboxFilter, + Snapshot, + SnapshotFilter, +) +from opensandbox_server.services.factory import create_sandbox_service +from opensandbox_server.services.snapshot_service import create_snapshot_service +from opensandbox_server.api.lifecycle_helpers import ( + apply_reserved_metadata_for_create, + authorize_mutating_action, + authorize_snapshot_scope, + get_principal, + log_mutation_audit, + merge_list_scope_from_request, + strip_reserved_metadata_from_patch, +) +from opensandbox_server.middleware.authorization import LifecycleAction, authorize_action, is_user_scoped + +# Initialize router +router = APIRouter(tags=["Sandboxes"]) + +# Initialize service based on configuration from config.toml (defaults to docker) +sandbox_service = create_sandbox_service() +snapshot_service = create_snapshot_service(sandbox_service) + + +# ============================================================================ +# Sandbox CRUD Operations +# ============================================================================ + +@router.post( + "/sandboxes", + response_model=CreateSandboxResponse, + response_model_exclude_none=True, + status_code=status.HTTP_202_ACCEPTED, + responses={ + 202: {"description": "Sandbox creation accepted for asynchronous provisioning"}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def create_sandbox( + http_request: Request, + body: CreateSandboxRequest, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> CreateSandboxResponse: + """ + Create a sandbox from a container image. + + Creates a new sandbox from a container image with optional resource limits, + environment variables, and metadata. Sandboxes are provisioned directly from + the specified image without requiring a pre-created template. + + Args: + body: Sandbox creation request + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + CreateSandboxResponse: Accepted sandbox creation request + + Raises: + HTTPException: If sandbox creation scheduling fails + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.CREATE, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + body = apply_reserved_metadata_for_create(body, principal, cfg) + validate_extensions(body.extensions) + if body.snapshot_id and is_user_scoped(principal): + snap = snapshot_service.get_snapshot(body.snapshot_id) + authorize_snapshot_scope( + principal, + snap, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_service=sandbox_service, + ) + try: + res = await sandbox_service.create_sandbox(body) + log_mutation_audit( + http_request, action=LifecycleAction.CREATE, sandbox_id=res.id, outcome="success" + ) + return res + except HTTPException as exc: + err = exc.detail + if isinstance(err, dict): + code = err.get("code") + else: + code = None + log_mutation_audit( + http_request, + action=LifecycleAction.CREATE, + sandbox_id=None, + outcome="error", + error_code=code, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.CREATE, + sandbox_id=None, + outcome="error", + error_code="UNEXPECTED", + ) + raise + + +# Search endpoint +@router.get( + "/sandboxes", + response_model=ListSandboxesResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Paginated collection of sandboxes"}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def list_sandboxes( + http_request: Request, + state: Optional[List[str]] = Query(None, description="Filter by lifecycle state. Pass multiple times for OR logic."), + metadata: Optional[str] = Query(None, description="Arbitrary metadata key-value pairs for filtering (URL encoded)."), + page: int = Query(1, ge=1, description="Page number for pagination"), + page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"), + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> ListSandboxesResponse: + """ + List sandboxes with optional filtering and pagination. + + List all sandboxes with optional filtering and pagination using query parameters. + All filter conditions use AND logic. Multiple `state` parameters use OR logic within states. + + Args: + state: Filter by lifecycle state. + metadata: Arbitrary metadata key-value pairs for filtering. + page: Page number for pagination. + page_size: Number of items per page. + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + ListSandboxesResponse: Paginated list of sandboxes + """ + # Parse metadata query string into dictionary + metadata_dict = {} + if metadata: + from urllib.parse import parse_qsl + try: + # Parse query string format: key=value&key2=value2 + # strict_parsing=True rejects malformed segments like "a=1&broken" + parsed = parse_qsl(metadata, keep_blank_values=True, strict_parsing=True) + metadata_dict = dict(parsed) + except Exception as e: + from fastapi import HTTPException + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "INVALID_METADATA_FORMAT", "message": f"Invalid metadata format: {str(e)}"} + ) + + # Construct request object + list_req = ListSandboxesRequest( + filter=SandboxFilter(state=state, metadata=metadata_dict if metadata_dict else None), + pagination=PaginationRequest(page=page, pageSize=page_size), + ) + + import logging + + logger = logging.getLogger(__name__) + logger.info("ListSandboxes: %s", list_req.filter) + + cfg = get_config() + principal = get_principal(http_request) + authorize_action( + principal, + LifecycleAction.LIST_SANDBOXES, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + list_req = merge_list_scope_from_request(http_request, list_req, cfg) + + # Delegate to the service layer for filtering and pagination + return sandbox_service.list_sandboxes(list_req) + + +@router.get( + "/sandboxes/{sandbox_id}", + response_model=Sandbox, + response_model_exclude_none=True, + responses={ + 200: {"description": "Sandbox current state and metadata"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def get_sandbox( + http_request: Request, + sandbox_id: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Sandbox: + """ + Fetch a sandbox by id. + + Returns the complete sandbox information including image specification, + status, metadata, and timestamps. + + Args: + sandbox_id: Unique sandbox identifier + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + Sandbox: Complete sandbox information + + Raises: + HTTPException: If sandbox not found or access denied + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_action( + principal, + LifecycleAction.GET_SANDBOX, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + box = sandbox_service.get_sandbox(sandbox_id) + authorize_action( + principal, + LifecycleAction.GET_SANDBOX, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox=box, + ) + return box + + +@router.patch( + "/sandboxes/{sandbox_id}/metadata", + response_model=Sandbox, + response_model_exclude_none=True, + responses={ + 200: {"description": "Metadata patched successfully. Returns the complete sandbox with updated metadata."}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def patch_sandbox_metadata( + http_request: Request, + sandbox_id: str, + patch: PatchSandboxMetadataRequest = Body(...), + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Sandbox: + """ + Patch sandbox metadata via JSON Merge Patch (RFC 7396). + Non-null adds/replaces, null deletes, absent keeps. + Read-modify-write without optimistic locking — concurrent PATCH may drop updates. + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.PATCH_METADATA, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + ) + try: + box = sandbox_service.get_sandbox(sandbox_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + log_mutation_audit( + http_request, + action=LifecycleAction.PATCH_METADATA, + sandbox_id=sandbox_id, + outcome="not_found", + ) + raise + authorize_mutating_action( + http_request, + principal, + LifecycleAction.PATCH_METADATA, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + sandbox=box, + ) + safe_patch = strip_reserved_metadata_from_patch( + patch, + principal, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + try: + result = sandbox_service.patch_sandbox_metadata(sandbox_id, safe_patch) + log_mutation_audit( + http_request, + action=LifecycleAction.PATCH_METADATA, + sandbox_id=sandbox_id, + outcome="success", + ) + return result + except HTTPException as exc: + err = exc.detail + log_mutation_audit( + http_request, + action=LifecycleAction.PATCH_METADATA, + sandbox_id=sandbox_id, + outcome="error", + error_code=err.get("code") if isinstance(err, dict) else None, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.PATCH_METADATA, + sandbox_id=sandbox_id, + outcome="error", + error_code="UNEXPECTED", + ) + raise + + +@router.delete( + "/sandboxes/{sandbox_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 204: {"description": "Sandbox successfully deleted"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def delete_sandbox( + http_request: Request, + sandbox_id: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Response: + """ + Delete a sandbox. + + Terminates sandbox execution. The sandbox will transition through Stopping state to Terminated. + + Args: + sandbox_id: Unique sandbox identifier + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + Response: 204 No Content + + Raises: + HTTPException: If sandbox not found or deletion fails + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.DELETE, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + ) + try: + box = sandbox_service.get_sandbox(sandbox_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE, + sandbox_id=sandbox_id, + outcome="not_found", + ) + raise + authorize_mutating_action( + http_request, + principal, + LifecycleAction.DELETE, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + sandbox=box, + ) + try: + sandbox_service.delete_sandbox(sandbox_id) + log_mutation_audit( + http_request, action=LifecycleAction.DELETE, sandbox_id=sandbox_id, outcome="success" + ) + except HTTPException as exc: + err = exc.detail + if isinstance(err, dict): + code = err.get("code") + else: + code = None + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE, + sandbox_id=sandbox_id, + outcome="error", + error_code=code, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE, + sandbox_id=sandbox_id, + outcome="error", + error_code="UNEXPECTED", + ) + raise + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +# ============================================================================ +# Sandbox Lifecycle Operations +# ============================================================================ + +@router.post( + "/sandboxes/{sandbox_id}/pause", + status_code=status.HTTP_202_ACCEPTED, + responses={ + 202: {"description": "Pause operation accepted"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def pause_sandbox( + http_request: Request, + sandbox_id: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Response: + """ + Pause execution while retaining state. + + Pauses a running sandbox while preserving its state. + Poll GET /sandboxes/{sandboxId} to track state transition through Pausing and eventually Paused. + + Args: + sandbox_id: Unique sandbox identifier + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + Response: 202 Accepted + + Raises: + HTTPException: If sandbox not found or cannot be paused + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.PAUSE, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + ) + try: + box = sandbox_service.get_sandbox(sandbox_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + log_mutation_audit( + http_request, + action=LifecycleAction.PAUSE, + sandbox_id=sandbox_id, + outcome="not_found", + ) + raise + authorize_mutating_action( + http_request, + principal, + LifecycleAction.PAUSE, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + sandbox=box, + ) + try: + sandbox_service.pause_sandbox(sandbox_id) + log_mutation_audit( + http_request, action=LifecycleAction.PAUSE, sandbox_id=sandbox_id, outcome="success" + ) + except HTTPException as exc: + err = exc.detail + if isinstance(err, dict): + code = err.get("code") + else: + code = None + log_mutation_audit( + http_request, + action=LifecycleAction.PAUSE, + sandbox_id=sandbox_id, + outcome="error", + error_code=code, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.PAUSE, + sandbox_id=sandbox_id, + outcome="error", + error_code="UNEXPECTED", + ) + raise + return Response(status_code=status.HTTP_202_ACCEPTED) + + +@router.post( + "/sandboxes/{sandbox_id}/resume", + status_code=status.HTTP_202_ACCEPTED, + responses={ + 202: {"description": "Resume operation accepted"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def resume_sandbox( + http_request: Request, + sandbox_id: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Response: + """ + Resume a paused sandbox. + + Resumes execution of a paused sandbox. + Poll GET /sandboxes/{sandboxId} to track state transition through Resuming and eventually Running. + + Args: + sandbox_id: Unique sandbox identifier + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + Response: 202 Accepted + + Raises: + HTTPException: If sandbox not found or cannot be resumed + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.RESUME, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + ) + try: + box = sandbox_service.get_sandbox(sandbox_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + log_mutation_audit( + http_request, + action=LifecycleAction.RESUME, + sandbox_id=sandbox_id, + outcome="not_found", + ) + raise + authorize_mutating_action( + http_request, + principal, + LifecycleAction.RESUME, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + sandbox=box, + ) + try: + sandbox_service.resume_sandbox(sandbox_id) + log_mutation_audit( + http_request, action=LifecycleAction.RESUME, sandbox_id=sandbox_id, outcome="success" + ) + except HTTPException as exc: + err = exc.detail + if isinstance(err, dict): + code = err.get("code") + else: + code = None + log_mutation_audit( + http_request, + action=LifecycleAction.RESUME, + sandbox_id=sandbox_id, + outcome="error", + error_code=code, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.RESUME, + sandbox_id=sandbox_id, + outcome="error", + error_code="UNEXPECTED", + ) + raise + return Response(status_code=status.HTTP_202_ACCEPTED) + + +@router.post( + "/sandboxes/{sandbox_id}/renew-expiration", + response_model=RenewSandboxExpirationResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Sandbox expiration updated successfully"}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def renew_sandbox_expiration( + http_request: Request, + sandbox_id: str, + renew_body: RenewSandboxExpirationRequest, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> RenewSandboxExpirationResponse: + """ + Renew sandbox expiration. + + Renews the absolute expiration time of a sandbox. + The new expiration time must be in the future and after the current expiresAt time. + + Args: + sandbox_id: Unique sandbox identifier + renew_body: Renewal request with new expiration time + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + RenewSandboxExpirationResponse: Updated expiration time + + Raises: + HTTPException: If sandbox not found or renewal fails + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.RENEW, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + ) + try: + box = sandbox_service.get_sandbox(sandbox_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + log_mutation_audit( + http_request, + action=LifecycleAction.RENEW, + sandbox_id=sandbox_id, + outcome="not_found", + ) + raise + authorize_mutating_action( + http_request, + principal, + LifecycleAction.RENEW, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + sandbox=box, + ) + try: + res = sandbox_service.renew_expiration(sandbox_id, renew_body) + log_mutation_audit( + http_request, action=LifecycleAction.RENEW, sandbox_id=sandbox_id, outcome="success" + ) + return res + except HTTPException as exc: + err = exc.detail + if isinstance(err, dict): + code = err.get("code") + else: + code = None + log_mutation_audit( + http_request, + action=LifecycleAction.RENEW, + sandbox_id=sandbox_id, + outcome="error", + error_code=code, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.RENEW, + sandbox_id=sandbox_id, + outcome="error", + error_code="UNEXPECTED", + ) + raise + + +# ============================================================================ +# Snapshot Operations +# ============================================================================ + +@router.post( + "/sandboxes/{sandbox_id}/snapshots", + tags=["Snapshots"], + response_model=Snapshot, + response_model_exclude_none=True, + status_code=status.HTTP_202_ACCEPTED, + responses={ + 202: {"description": "Snapshot creation accepted"}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"}, + 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def create_snapshot( + http_request: Request, + sandbox_id: str, + response: Response, + request: Optional[CreateSnapshotRequest] = None, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Snapshot: + """ + Create a persistent point-in-time snapshot from a sandbox. + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.CREATE_SNAPSHOT, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + ) + box = sandbox_service.get_sandbox(sandbox_id) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.CREATE_SNAPSHOT, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=sandbox_id, + sandbox=box, + ) + create_request = request or CreateSnapshotRequest() + # Derive snapshot scope from the source sandbox's metadata so every snapshot + # carries ownership data regardless of whether the creator is a user principal + # or a service-admin API key. + box_meta: dict = {} + if isinstance(box, dict): + box_meta = box.get("metadata") or {} + else: + box_meta = box.metadata or {} + snap_access_owner = box_meta.get(cfg.authz.owner_metadata_key) or None + snap_access_team = box_meta.get(cfg.authz.team_metadata_key) or None + try: + snapshot = await asyncio.to_thread( + snapshot_service.create_snapshot, + sandbox_id, + create_request, + access_owner=snap_access_owner, + access_team=snap_access_team, + ) + log_mutation_audit( + http_request, action=LifecycleAction.CREATE_SNAPSHOT, sandbox_id=sandbox_id, outcome="success" + ) + except HTTPException as exc: + err = exc.detail + log_mutation_audit( + http_request, + action=LifecycleAction.CREATE_SNAPSHOT, + sandbox_id=sandbox_id, + outcome="error", + error_code=err.get("code") if isinstance(err, dict) else None, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.CREATE_SNAPSHOT, + sandbox_id=sandbox_id, + outcome="error", + error_code="UNEXPECTED", + ) + raise + response.headers["Location"] = f"/v1/snapshots/{snapshot.id}" + return snapshot + + +@router.get( + "/snapshots", + tags=["Snapshots"], + response_model=ListSnapshotsResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Paginated collection of snapshots"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def list_snapshots( + http_request: Request, + sandbox_id: Optional[str] = Query(None, alias="sandboxId", description="Filter snapshots by source sandbox identifier"), + state: Optional[List[str]] = Query(None, description="Filter by snapshot lifecycle state. Pass multiple times for OR logic."), + page: int = Query(1, ge=1, description="Page number for pagination"), + page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"), + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> ListSnapshotsResponse: + """ + List snapshots with optional filtering and pagination. + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_action( + principal, + LifecycleAction.LIST_SNAPSHOTS, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + + snap_access_owner: Optional[str] = None + snap_access_team: Optional[str] = None + if is_user_scoped(principal): + # Scope to the caller's own snapshots via stored ownership metadata. + # A live sandbox lookup is intentionally avoided: snapshots outlive their + # source sandbox and must remain listable after it is deleted. + snap_access_owner = principal.canonical_owner + snap_access_team = principal.canonical_team + + request = ListSnapshotsRequest( + filter=SnapshotFilter(sandboxId=sandbox_id, state=state), + pagination=PaginationRequest(page=page, pageSize=page_size), + ) + return snapshot_service.list_snapshots( + request, + access_owner=snap_access_owner, + access_team=snap_access_team, + ) + + +@router.get( + "/snapshots/{snapshot_id}", + tags=["Snapshots"], + response_model=Snapshot, + response_model_exclude_none=True, + responses={ + 200: {"description": "Snapshot current state and metadata"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def get_snapshot( + http_request: Request, + snapshot_id: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Snapshot: + """ + Fetch a snapshot by id. + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_action( + principal, + LifecycleAction.GET_SNAPSHOT, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + snap = snapshot_service.get_snapshot(snapshot_id) + authorize_snapshot_scope( + principal, + snap, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_service=sandbox_service, + ) + return snap + + +@router.delete( + "/snapshots/{snapshot_id}", + tags=["Snapshots"], + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 204: {"description": "Snapshot successfully deleted"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 409: {"model": ErrorResponse, "description": "The snapshot is not in a deletable state or is still in use"}, + 501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def delete_snapshot( + http_request: Request, + snapshot_id: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Response: + """ + Delete a snapshot by id. + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_mutating_action( + http_request, + principal, + LifecycleAction.DELETE_SNAPSHOT, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_id=None, + ) + if is_user_scoped(principal): + try: + snap = snapshot_service.get_snapshot(snapshot_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE_SNAPSHOT, + sandbox_id=None, + outcome="not_found", + ) + raise + try: + authorize_snapshot_scope( + principal, + snap, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox_service=sandbox_service, + ) + except HTTPException: + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE_SNAPSHOT, + sandbox_id=None, + outcome="forbidden", + ) + raise + try: + snapshot_service.delete_snapshot(snapshot_id) + log_mutation_audit( + http_request, action=LifecycleAction.DELETE_SNAPSHOT, sandbox_id=None, outcome="success" + ) + except HTTPException as exc: + err = exc.detail + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE_SNAPSHOT, + sandbox_id=None, + outcome="error", + error_code=err.get("code") if isinstance(err, dict) else None, + ) + raise + except Exception: + log_mutation_audit( + http_request, + action=LifecycleAction.DELETE_SNAPSHOT, + sandbox_id=None, + outcome="error", + error_code="UNEXPECTED", + ) + raise + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +# ============================================================================ +# Sandbox Endpoints +# ============================================================================ + +@router.get( + "/sandboxes/{sandbox_id}/endpoints/{port}", + response_model=Endpoint, + response_model_exclude_none=True, + responses={ + 200: {"description": "Endpoint retrieved successfully"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"}, + 404: {"model": ErrorResponse, "description": "The requested resource does not exist"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def get_sandbox_endpoint( + http_request: Request, + sandbox_id: str, + port: int, + use_server_proxy: bool = Query(False, description="Whether to return a server-proxied URL"), + expires: Optional[int] = Query(None, description="Request a signed route token with this Unix epoch second expiration. Requires ingress gateway with secure_access configured."), + x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"), +) -> Endpoint: + """ + Get sandbox access endpoint. + + Returns the public access endpoint URL for accessing a service running on a specific port + within the sandbox. The service must be listening on the specified port inside the sandbox + for the endpoint to be available. + + When the ``expires`` query parameter is provided, the endpoint is wrapped in a + cryptographically signed route token (OSEP-0011) instead of returning a plain URL. + This requires the ingress gateway to be configured with secure_access signing keys. + + Args: + http_request: FastAPI request object + sandbox_id: Unique sandbox identifier + port: Port number where the service is listening inside the sandbox (1-65535) + use_server_proxy: Whether to return a server-proxied URL + expires: Unix epoch seconds for signed route token expiration. Must be a + non-negative uint64 value. When omitted or invalid, a plain (unsigned) + endpoint is returned. + x_request_id: Unique request identifier for tracing (optional; server generates if omitted). + + Returns: + Endpoint: Public endpoint URL + + Raises: + HTTPException: If sandbox not found, endpoint not available, or signed + routes are not supported by the runtime/configuration (400). + """ + cfg = get_config() + principal = get_principal(http_request) + authorize_action( + principal, + LifecycleAction.GET_ENDPOINT, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + ) + box = sandbox_service.get_sandbox(sandbox_id) + authorize_action( + principal, + LifecycleAction.GET_ENDPOINT, + owner_key=cfg.authz.owner_metadata_key, + team_key=cfg.authz.team_metadata_key, + sandbox=box, + ) + # Delegate to the service layer for endpoint resolution + endpoint = sandbox_service.get_endpoint(sandbox_id, port, expires=expires) + + if use_server_proxy: + # Prefer configured external address when available. + base_url = str(http_request.base_url).rstrip("/") + eip = (get_config().server.eip or "").strip().rstrip("/") + if eip: + base_url = eip + base_url = base_url.replace("https://", "").replace("http://", "") + endpoint.endpoint = f"{base_url}/sandboxes/{sandbox_id}/proxy/{port}" + + return endpoint diff --git a/server/opensandbox_server/api/lifecycle_helpers.py b/server/opensandbox_server/api/lifecycle_helpers.py new file mode 100644 index 000000000..70056879d --- /dev/null +++ b/server/opensandbox_server/api/lifecycle_helpers.py @@ -0,0 +1,185 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +"""Shared helpers for lifecycle routes: scoping, reserved metadata, audit logging.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from fastapi import Request, status +from fastapi.exceptions import HTTPException + +from opensandbox_server.api.schema import CreateSandboxRequest, ListSandboxesRequest, SandboxFilter +from opensandbox_server.config import AppConfig +from opensandbox_server.middleware.request_id import get_request_id +from opensandbox_server.middleware.authorization import authorize_action, is_user_scoped, sandbox_in_scope +from opensandbox_server.middleware.principal import Principal + +logger = logging.getLogger(__name__) + + +def get_principal(request: Request) -> Optional[Principal]: + return getattr(request.state, "principal", None) + + +def merge_list_scope_from_request(http_request: Request, body: ListSandboxesRequest, config: AppConfig) -> ListSandboxesRequest: + """AND server-side owner/team scope into list metadata filters for user principals.""" + return _merge_list_scope_inner(body, get_principal(http_request), config) + + +def _merge_list_scope_inner( + request: ListSandboxesRequest, + principal: Optional[Principal], + config: AppConfig, +) -> ListSandboxesRequest: + if not is_user_scoped(principal): + return request + assert principal is not None + owner_k = config.authz.owner_metadata_key + team_k = config.authz.team_metadata_key + meta = dict(request.filter.metadata or {}) + meta[owner_k] = principal.canonical_owner + if principal.canonical_team is not None: + meta[team_k] = principal.canonical_team + new_filter = SandboxFilter( + state=request.filter.state, + metadata=meta, + ) + return ListSandboxesRequest(filter=new_filter, pagination=request.pagination) + + +def apply_reserved_metadata_for_create( + req: CreateSandboxRequest, + principal: Optional[Principal], + config: AppConfig, +) -> CreateSandboxRequest: + if not is_user_scoped(principal): + return req + assert principal is not None + meta = dict(req.metadata or {}) + meta[config.authz.owner_metadata_key] = principal.canonical_owner + if principal.canonical_team is not None: + meta[config.authz.team_metadata_key] = principal.canonical_team + return req.model_copy(update={"metadata": meta}) + + +def authorize_snapshot_scope( + principal: Optional[Principal], + snapshot, + *, + owner_key: str, + team_key: str, + sandbox_service, +) -> None: + """Enforce owner/team scope for a snapshot. + + Scope is checked using the snapshot's persisted access_owner/access_team + fields (populated at snapshot-create time) so that the check remains valid + even after the source sandbox has been deleted. + + For snapshots that pre-date scope metadata (access_owner is None), the check + falls back to resolving the source sandbox. If the source sandbox no longer + exists we allow access: we cannot prove a mismatch and snapshots must remain + reachable after sandbox teardown. + + Service admins and API-key-only principals always pass through. + """ + if not is_user_scoped(principal): + return + + _OUT_OF_SCOPE = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "OUT_OF_SCOPE", + "message": "The snapshot is outside the authenticated user owner/team scope.", + }, + ) + + if snapshot.access_owner is not None: + # Snapshot has stored scope metadata — compare directly without a live + # sandbox lookup, so deleted source sandboxes never block access. + if snapshot.access_owner.strip() != principal.canonical_owner: + raise _OUT_OF_SCOPE + if principal.canonical_team is not None: + if (snapshot.access_team or "").strip() != principal.canonical_team: + raise _OUT_OF_SCOPE + return + + # Legacy snapshot without stored scope metadata: resolve via source sandbox. + try: + box = sandbox_service.get_sandbox(snapshot.sandbox_id) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + # Source sandbox deleted and no stored scope — cannot prove a mismatch; allow. + return + raise # 500 / 503 / etc. — fail closed rather than exposing data + if not sandbox_in_scope(principal, box, owner_key, team_key): + raise _OUT_OF_SCOPE + + +def authorize_mutating_action( + request: Request, + principal: Optional[Principal], + action: str, + *, + owner_key: str, + team_key: str, + sandbox_id: Optional[str] = None, + sandbox=None, +) -> None: + """Calls authorize_action and emits a mutation_audit entry when 403 is raised.""" + try: + authorize_action(principal, action, owner_key=owner_key, team_key=team_key, sandbox=sandbox) + except HTTPException: + log_mutation_audit(request, action=action, sandbox_id=sandbox_id, outcome="forbidden") + raise + + +def strip_reserved_metadata_from_patch( + patch: dict, + principal: Optional[Principal], + *, + owner_key: str, + team_key: str, +) -> dict: + """Remove reserved scope keys from a metadata patch for non-service-admin principals. + + Prevents user principals from altering access.owner / access.team through + the PATCH endpoint, which would let them escape their own scope boundary. + """ + if principal is None or principal.is_service_admin: + return patch + return {k: v for k, v in patch.items() if k not in (owner_key, team_key)} + + +def log_mutation_audit( + request: Request, + *, + action: str, + sandbox_id: Optional[str], + outcome: str, + error_code: Optional[str] = None, +) -> None: + principal = get_principal(request) + rid = get_request_id() or request.headers.get("X-Request-ID") or "-" + subj = getattr(principal, "subject", None) if principal else None + team = getattr(principal, "canonical_team", None) if principal else None + role = getattr(principal, "role", None) if principal else None + src = getattr(principal, "source", None) if principal else None + logger.info( + "mutation_audit request_id=%s action=%s sandbox_id=%s outcome=%s error_code=%s " + "principal_source=%s principal_subject=%s principal_team=%s principal_role=%s", + rid, + action, + sandbox_id, + outcome, + error_code, + src, + subj, + team, + role, + ) diff --git a/server/opensandbox_server/api/schema.py b/server/opensandbox_server/api/schema.py index f3be288e0..4685edb8c 100644 --- a/server/opensandbox_server/api/schema.py +++ b/server/opensandbox_server/api/schema.py @@ -623,6 +623,10 @@ class Snapshot(BaseModel): alias="createdAt", description="Snapshot creation timestamp", ) + # Internal scope metadata — excluded from API serialization; used by + # authorize_snapshot_scope so deleted source sandboxes don't block access. + access_owner: Optional[str] = Field(default=None, exclude=True) + access_team: Optional[str] = Field(default=None, exclude=True) class Config: populate_by_name = True diff --git a/server/opensandbox_server/config.py b/server/opensandbox_server/config.py index 369c716d7..881c0344b 100644 --- a/server/opensandbox_server/config.py +++ b/server/opensandbox_server/config.py @@ -55,6 +55,9 @@ EGRESS_MODE_DNS = "dns" EGRESS_MODE_DNS_NFT = "dns+nft" +AUTH_MODE_API_KEY_ONLY = "api_key_only" +AUTH_MODE_API_KEY_AND_USER = "api_key_and_user" +USER_MODE_TRUSTED_HEADER = "trusted_header" def _is_valid_ip(host: str) -> bool: @@ -372,6 +375,96 @@ def validate_ingress_mode(self) -> "IngressConfig": return self +class TrustedHeaderConfig(BaseModel): + """Identity headers set by a reverse proxy in trusted-header user mode (OSEP-0006).""" + + user_header: str = Field( + default="X-OpenSandbox-User", + min_length=1, + description="Header carrying the end-user subject id (required for user auth).", + ) + team_header: str = Field( + default="X-OpenSandbox-Team", + min_length=1, + description="Optional team id for owner/team scope.", + ) + roles_header: str = Field( + default="X-OpenSandbox-Roles", + min_length=1, + description="Comma-separated roles (read_only, operator) for the end user.", + ) + + +class AuthConfig(BaseModel): + """High-level authentication behavior (API key, optional user path for the console).""" + + mode: Literal[AUTH_MODE_API_KEY_ONLY, AUTH_MODE_API_KEY_AND_USER] = Field( + default=AUTH_MODE_API_KEY_ONLY, + description='Use "api_key_only" (default) or "api_key_and_user" for console + proxy identity headers.', + ) + user_mode: Literal[USER_MODE_TRUSTED_HEADER] = Field( + default=USER_MODE_TRUSTED_HEADER, + description="How user identity is obtained when mode is api_key_and_user. Phase 1: trusted_header only.", + ) + trusted_header: TrustedHeaderConfig = Field( + default_factory=TrustedHeaderConfig, + description="Header names for trusted user/team/roles (when user_mode = trusted_header).", + ) + + +class AuthzConfig(BaseModel): + """Role defaults and owner/team metadata keys for resource scoping (OSEP-0006).""" + + default_role: Literal["read_only", "operator"] = Field( + default="read_only", + description="When not overridden by subject lists or roles header.", + ) + owner_metadata_key: str = Field( + default="access.owner", + min_length=1, + description="Reserved label key for scope owner (injected on create, enforced on read/mutate).", + ) + team_metadata_key: str = Field( + default="access.team", + min_length=1, + description="Reserved label key for team; optional when the team header is absent.", + ) + operator_subjects: list[str] = Field( + default_factory=list, + description="Raw user subject values that always receive the operator role.", + ) + read_only_subjects: list[str] = Field( + default_factory=list, + description="Raw user subject values that always receive the read_only role.", + ) + + +class ConsoleConfig(BaseModel): + """Static hosting of the developer console SPA (optional).""" + + enabled: bool = Field( + default=False, + description="If true and the dist directory exists, mount the console under mount_path.", + ) + mount_path: str = Field( + default="/console", + min_length=1, + description="URL prefix for the single-page app (Vite base should match).", + ) + + @model_validator(mode="after") + def validate_mount_path(self) -> "ConsoleConfig": + mount = self.mount_path.rstrip("/") or "/" + reserved_exact = {"/", "/v1", "/health", "/docs", "/redoc", "/openapi.json"} + reserved_prefixes = ("/v1/", "/sandboxes") + if mount in reserved_exact or any(mount.startswith(p) for p in reserved_prefixes): + raise ValueError( + "console.mount_path must not overlap API or system routes " + "(/, /v1, /v1/*, /sandboxes*, /health, /docs, /redoc, /openapi.json)." + ) + return self + + class LogConfig(BaseModel): """Logging configuration.""" @@ -859,6 +952,9 @@ class AppConfig(BaseModel): """Root application configuration model.""" server: ServerConfig = Field(default_factory=ServerConfig) + auth: AuthConfig = Field(default_factory=AuthConfig) + authz: AuthzConfig = Field(default_factory=AuthzConfig) + console: ConsoleConfig = Field(default_factory=ConsoleConfig) log: LogConfig = Field( default_factory=LogConfig, description="Logging configuration (level, file output, rotation).", @@ -999,6 +1095,13 @@ def get_config_path() -> Path: __all__ = [ "AppConfig", + "AuthConfig", + "AuthzConfig", + "AUTH_MODE_API_KEY_ONLY", + "AUTH_MODE_API_KEY_AND_USER", + "ConsoleConfig", + "TrustedHeaderConfig", + "USER_MODE_TRUSTED_HEADER", "RenewIntentConfig", "RenewIntentRedisConfig", "ServerConfig", diff --git a/server/opensandbox_server/examples/example.config.toml b/server/opensandbox_server/examples/example.config.toml index fa4c84fe3..dc24df88d 100644 --- a/server/opensandbox_server/examples/example.config.toml +++ b/server/opensandbox_server/examples/example.config.toml @@ -70,3 +70,25 @@ mode = "dns" [renew_intent] enabled = false min_interval_seconds = 60 + +# --- Developer console & dual auth (OSEP-0006) --- +# [auth] +# mode = "api_key_only" # default: API key only (backward compatible) +# mode = "api_key_and_user" # optional: trusted identity headers for the console +# user_mode = "trusted_header" +# +# [auth.trusted_header] +# user_header = "X-OpenSandbox-User" +# team_header = "X-OpenSandbox-Team" +# roles_header = "X-OpenSandbox-Roles" +# +# [authz] +# default_role = "read_only" +# owner_metadata_key = "access.owner" +# team_metadata_key = "access.team" +# operator_subjects = [] +# read_only_subjects = [] +# +# [console] +# enabled = false +# mount_path = "/console" diff --git a/server/opensandbox_server/main.py b/server/opensandbox_server/main.py index f33633ed7..2f8b7e59c 100644 --- a/server/opensandbox_server/main.py +++ b/server/opensandbox_server/main.py @@ -39,6 +39,7 @@ app_config = load_config() _log_config = configure_logging(app_config.log) +from opensandbox_server.api.auth import router as auth_router # noqa: E402 from opensandbox_server.api.devops import router as devops_router # noqa: E402 from opensandbox_server.api.pool import router as pool_router # noqa: E402 from opensandbox_server.api.lifecycle import router, sandbox_service, snapshot_service # noqa: E402 @@ -149,14 +150,50 @@ async def lifespan(app: FastAPI): # IMPORTANT: devops_router and pool_router MUST be registered before proxy_router # because proxy_router contains catch-all routes that would swallow diagnostics paths. app.include_router(router) +app.include_router(auth_router) app.include_router(devops_router) app.include_router(pool_router) app.include_router(proxy_router) app.include_router(router, prefix="/v1") +app.include_router(auth_router, prefix="/v1") app.include_router(devops_router, prefix="/v1") app.include_router(pool_router, prefix="/v1") app.include_router(proxy_router, prefix="/v1") +# Optional static hosting of the developer console (OSEP-0006) +if app_config.console.enabled: + from pathlib import Path + + from starlette.exceptions import HTTPException as _StarletteHTTPException + from starlette.staticfiles import StaticFiles + + class _SPAStaticFiles(StaticFiles): + """Serve index.html for unknown paths so BrowserRouter client-side routes work.""" + + async def get_response(self, path: str, scope): + try: + return await super().get_response(path, scope) + except _StarletteHTTPException as exc: + if exc.status_code == 404: + return await super().get_response("index.html", scope) + raise + + _console_dist = Path(__file__).resolve().parent.parent.parent / "console" / "dist" + if _console_dist.is_dir(): + _mount = app_config.console.mount_path.rstrip("/") or "/console" + app.mount( + _mount, + _SPAStaticFiles(directory=str(_console_dist), html=True), + name="console", + ) + else: + import logging as _logging + _logging.getLogger(__name__).warning( + "console.enabled = true but console/dist was not found at %s; " + "the console will not be served. Run 'npm run build' in the console/ directory.", + _console_dist, + ) + DEFAULT_ERROR_CODE = "GENERAL::UNKNOWN_ERROR" DEFAULT_ERROR_MESSAGE = "An unexpected error occurred." diff --git a/server/opensandbox_server/middleware/auth.py b/server/opensandbox_server/middleware/auth.py index 323bdc9d4..bf7dd8176 100644 --- a/server/opensandbox_server/middleware/auth.py +++ b/server/opensandbox_server/middleware/auth.py @@ -2,21 +2,9 @@ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. """ -Authentication middleware for OpenSandbox Lifecycle API. - -This module implements API Key authentication as specified in the OpenAPI spec. -API keys are configured via config.toml and validated against the OPEN-SANDBOX-API-KEY header. +Authentication middleware: API key path (legacy) and optional user identity (OSEP-0006). """ import re @@ -26,109 +14,151 @@ from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware -from opensandbox_server.config import AppConfig, get_config - -SANDBOX_API_KEY_HEADER = "OPEN-SANDBOX-API-KEY" - +from opensandbox_server.config import ( + AUTH_MODE_API_KEY_AND_USER, + AUTH_MODE_API_KEY_ONLY, + USER_MODE_TRUSTED_HEADER, + AppConfig, + get_config, +) +from opensandbox_server.middleware.principal import build_user_principal, principal_for_api_key class AuthMiddleware(BaseHTTPMiddleware): """ - Middleware for API Key authentication. - - Validates the OPEN-SANDBOX-API-KEY header for all requests except health check. - Returns 401 Unauthorized if authentication fails. + Validates ``OPEN-SANDBOX-API-KEY`` when configured, with optional dual auth for the console. """ - # Paths that don't require authentication - EXEMPT_PATHS = ["/health", "/docs", "/redoc", "/openapi.json"] + API_KEY_HEADER = "OPEN-SANDBOX-API-KEY" - # Strict pattern for proxy-to-sandbox: /sandboxes/{id}/proxy/{port}/... with numeric port only. - # Matches the actual route in proxy.py; rejects path traversal (..) and malformed port. + EXEMPT_PATHS = ["/health", "/docs", "/redoc", "/openapi.json"] _PROXY_PATH_RE = re.compile(r"^(/v1)?/sandboxes/[^/]+/proxy/\d+(/|$)") @staticmethod def _is_proxy_path(path: str) -> bool: - """True only for the exact proxy-route shape; rejects path traversal (..).""" if ".." in path: return False return bool(AuthMiddleware._PROXY_PATH_RE.match(path)) - def __init__(self, app, config: Optional[AppConfig] = None): - """ - Initialize authentication middleware. + # API route prefixes that must never be bypassed by the console auth skip. + _PROTECTED_API_PREFIXES = ( + "/v1", + "/auth", + "/sandboxes", + "/snapshots", + "/pools", + "/devops", + ) + + @staticmethod + def _is_console_path(path: str, mount: str) -> bool: + if ".." in path: + return False + base = mount.rstrip("/") or "/console" + # Reject any mount that collides with known API route prefixes so a + # misconfigured mount_path (e.g. "/auth") cannot bypass authentication. + if any( + base == p or base.startswith(p + "/") + for p in AuthMiddleware._PROTECTED_API_PREFIXES + ): + return False + return path == base or path.startswith(base + "/") - Args: - app: FastAPI application instance - config: Optional application configuration (for dependency injection) - """ + def __init__(self, app, config: Optional[AppConfig] = None): super().__init__(app) self.config = config or get_config() - # Read the API key directly from config; suitable for dev/test usage self.valid_api_keys = self._load_api_keys() def _load_api_keys(self) -> set: - """ - Load valid API keys from configuration. - - Returns: - set: Set of valid API keys - """ - # Supports a single API key from config; extend later for secret managers api_key = self.config.server.api_key - # Treat empty string as no key configured if api_key and api_key.strip(): return {api_key} return set() - async def dispatch(self, request: Request, call_next: Callable) -> Response: - """ - Process each request and validate authentication. - - Args: - request: Incoming HTTP request - call_next: Next middleware or route handler + def _try_trusted_user_principal(self, request: Request): + if self.config.auth.user_mode != USER_MODE_TRUSTED_HEADER: + return None + th = self.config.auth.trusted_header + raw_user = request.headers.get(th.user_header) + if raw_user is None or not str(raw_user).strip(): + return None + raw_team = request.headers.get(th.team_header) + roles = request.headers.get(th.roles_header) + try: + return build_user_principal( + str(raw_user).strip(), + str(raw_team).strip() if raw_team is not None else None, + roles, + self.config.authz, + ) + except ValueError: + return None - Returns: - Response: HTTP response - """ - # Skip authentication for exempt paths + async def dispatch(self, request: Request, call_next: Callable) -> Response: if any(request.url.path.startswith(path) for path in self.EXEMPT_PATHS): return await call_next(request) - # Skip authentication only for the exact proxy-to-sandbox route shape - # (no path traversal, no loose substring match) if self._is_proxy_path(request.url.path): return await call_next(request) - # If no API keys are configured, skip authentication - if not self.valid_api_keys: + if self.config.console.enabled and self._is_console_path(request.url.path, self.config.console.mount_path): return await call_next(request) - # Extract API key from header - api_key = request.headers.get(SANDBOX_API_KEY_HEADER) + mode = self.config.auth.mode + has_keys = bool(self.valid_api_keys) + + # No API keys: open access (legacy) OR require user headers for api_key_and_user + if not has_keys: + if mode == AUTH_MODE_API_KEY_AND_USER: + principal = self._try_trusted_user_principal(request) + if principal is None: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "code": "MISSING_TRUSTED_IDENTITY", + "message": "User authentication requires trusted identity headers (e.g. " + f"{self.config.auth.trusted_header.user_header}).", + }, + ) + request.state.principal = principal + return await call_next(request) + return await call_next(request) + + api_key = request.headers.get(self.API_KEY_HEADER) + if api_key: + if api_key in self.valid_api_keys: + request.state.principal = principal_for_api_key() + return await call_next(request) + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "code": "INVALID_API_KEY", + "message": "Authentication credentials are invalid. " + "Check your API key and try again.", + }, + ) - # Validate API key - if not api_key: + if mode == AUTH_MODE_API_KEY_ONLY: return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ "code": "MISSING_API_KEY", "message": "Authentication credentials are missing. " - f"Provide API key via {SANDBOX_API_KEY_HEADER} header.", + "Provide API key via OPEN-SANDBOX-API-KEY header.", }, ) - # Enforce strict comparison whenever API keys are configured - if self.valid_api_keys and api_key not in self.valid_api_keys: + principal = self._try_trusted_user_principal(request) + if principal is None: return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ - "code": "INVALID_API_KEY", - "message": "Authentication credentials are invalid. " - "Check your API key and try again.", + "code": "MISSING_TRUSTED_IDENTITY", + "message": "User authentication requires trusted identity headers (e.g. " + f"{self.config.auth.trusted_header.user_header}).", }, ) + request.state.principal = principal + return await call_next(request) + - # Authentication successful, proceed to next middleware/handler - response = await call_next(request) - return response +SANDBOX_API_KEY_HEADER = AuthMiddleware.API_KEY_HEADER diff --git a/server/opensandbox_server/middleware/authorization.py b/server/opensandbox_server/middleware/authorization.py new file mode 100644 index 000000000..9e5cab68c --- /dev/null +++ b/server/opensandbox_server/middleware/authorization.py @@ -0,0 +1,147 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +""" +Lifecycle action authorization: role matrix + owner/team scope (OSEP-0006). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Optional + +from fastapi import status +from fastapi.exceptions import HTTPException + +from opensandbox_server.middleware.principal import Principal + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from opensandbox_server.api.schema import Sandbox + +ActionName = str + + +# Actions match OSEP-0006 lifecycle surface. +class LifecycleAction: + LIST_SANDBOXES = "list_sandboxes" + GET_SANDBOX = "get_sandbox" + GET_ENDPOINT = "get_endpoint" + CREATE = "create_sandbox" + RENEW = "renew_expiration" + DELETE = "delete_sandbox" + PAUSE = "pause_sandbox" + RESUME = "resume_sandbox" + LIST_SNAPSHOTS = "list_snapshots" + GET_SNAPSHOT = "get_snapshot" + CREATE_SNAPSHOT = "create_snapshot" + DELETE_SNAPSHOT = "delete_snapshot" + PATCH_METADATA = "patch_metadata" + + +_READ_ONLY = { + LifecycleAction.LIST_SANDBOXES, + LifecycleAction.GET_SANDBOX, + LifecycleAction.GET_ENDPOINT, + LifecycleAction.LIST_SNAPSHOTS, + LifecycleAction.GET_SNAPSHOT, +} +_OPERATOR = _READ_ONLY | { + LifecycleAction.CREATE, + LifecycleAction.RENEW, + LifecycleAction.DELETE, + LifecycleAction.PAUSE, + LifecycleAction.RESUME, + LifecycleAction.CREATE_SNAPSHOT, + LifecycleAction.DELETE_SNAPSHOT, + LifecycleAction.PATCH_METADATA, +} + + +def _actions_for_role(role: str) -> set[str] | None: + if role == "service_admin": + return None # all allowed; caller checks + if role == "operator": + return set(_OPERATOR) + if role == "read_only": + return set(_READ_ONLY) + return set() + + +def _scope_match( + principal: Principal, + owner_key: str, + team_key: str, + metadata: Optional[dict[str, str]], +) -> bool: + if principal.source not in ("user",): + return True + meta = metadata or {} + got_owner = (meta.get(owner_key) or "").strip() + if got_owner != principal.canonical_owner: + return False + if principal.canonical_team is not None: + if (meta.get(team_key) or "").strip() != principal.canonical_team: + return False + return True + + +def sandbox_in_scope( + principal: Optional[Principal], + sandbox: "Sandbox | dict", + owner_key: str, + team_key: str, +) -> bool: + if principal is None or principal.is_service_admin: + return True + if isinstance(sandbox, dict): + metadata = sandbox.get("metadata") + else: + metadata = sandbox.metadata + return _scope_match(principal, owner_key, team_key, metadata) + + +def authorize_action( + principal: Optional[Principal], + action: str, + *, + owner_key: str, + team_key: str, + sandbox: Optional["Sandbox | dict"] = None, +) -> None: + """ + Raise ``HTTPException``(403) if the action is not allowed for this principal, or + the sandbox (when provided) is out of owner/team scope. + Unauthenticated (``None``) principals are only allowed in dev mode (no API key configured); + the lifecycle layer should treat that as allow-all to preserve legacy tests. + """ + if principal is None: + # Dev mode: no API key configured. Log so misconfigured production deployments are visible. + logger.debug("authorize_action called with no principal (dev/open mode) — action=%s", action) + return + if principal.is_service_admin: + return + allowed = _actions_for_role(principal.role) + if allowed is not None and action not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "INSUFFICIENT_ROLE", + "message": f"Role '{principal.role}' is not allowed to perform this operation.", + }, + ) + if sandbox is not None and not sandbox_in_scope(principal, sandbox, owner_key, team_key): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "OUT_OF_SCOPE", + "message": "The sandbox is outside the authenticated user owner/team scope.", + }, + ) + + +def is_user_scoped(principal: Optional[Principal]) -> bool: + return bool(principal and principal.source == "user" and not principal.is_service_admin) diff --git a/server/opensandbox_server/middleware/principal.py b/server/opensandbox_server/middleware/principal.py new file mode 100644 index 000000000..0be886418 --- /dev/null +++ b/server/opensandbox_server/middleware/principal.py @@ -0,0 +1,120 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +""" +Authenticated principal (API key or trusted identity) for lifecycle authz and audit. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Optional + +from opensandbox_server.services.validators import LABEL_VALUE_RE + +if TYPE_CHECKING: + from opensandbox_server.config import AuthzConfig + +AuthRole = Literal["read_only", "operator", "service_admin"] +PrincipalSource = Literal["api_key", "user"] + + +@dataclass(frozen=True, slots=True) +class Principal: + """ + Runtime identity for authorization. ``service_admin`` (API key) bypasses owner/team scope. + """ + + source: PrincipalSource + subject: str + role: AuthRole + canonical_owner: str + canonical_team: Optional[str] = None + """When ``None`` (no team in trusted headers), only ``access.owner`` is enforced for scope.""" + + @property + def is_service_admin(self) -> bool: + return self.role == "service_admin" + + +def canonicalize_scoped_value(raw: str) -> str: + """ + Map an arbitrary string to a stable Kubernetes label value (≤63 chars) for metadata scope keys. + + Deterministic: the same input always maps to the same output. If the value is already + a valid label value, it is returned unchanged. + """ + s = (raw or "").strip() + if s == "": + return "" + if len(s) <= 63 and _is_valid_label_value(s): + return s + digest = hashlib.sha256(s.encode("utf-8")).hexdigest()[:32] + return digest + + +def _is_valid_label_value(value: str) -> bool: + if len(value) > 63: + return False + return bool(LABEL_VALUE_RE.match(value)) + + +def resolve_effective_role( + raw_subject: str, + roles_header_value: Optional[str], + authz: "AuthzConfig", +) -> AuthRole: + """ + Derive the effective role from static subject lists, then the roles header, then default. + """ + if raw_subject in authz.operator_subjects: + return "operator" + if raw_subject in authz.read_only_subjects: + return "read_only" + if roles_header_value: + parts = {p.strip().lower() for p in roles_header_value.split(",") if p.strip()} + if "operator" in parts or "op" in parts: + return "operator" + if "read_only" in parts or "readonly" in parts or "read-only" in parts: + return "read_only" + d = (authz.default_role or "read_only").lower() + if d == "operator": + return "operator" + return "read_only" + + +def principal_for_api_key() -> Principal: + return Principal( + source="api_key", + subject="api-key", + role="service_admin", + canonical_owner="", + canonical_team=None, + ) + + +def build_user_principal( + raw_subject: str, + raw_team: Optional[str], + roles_header: Optional[str], + authz: "AuthzConfig", +) -> Principal: + if not (raw_subject or "").strip(): + raise ValueError("raw_subject is required for user principal") + subj = (raw_subject or "").strip() + team_raw = (raw_team or "").strip() or None + role = resolve_effective_role(subj, roles_header, authz) + owner = canonicalize_scoped_value(subj) + if not owner: + raise ValueError("invalid subject after canonicalization") + team = canonicalize_scoped_value(team_raw) if team_raw else None + return Principal( + source="user", + subject=subj, + role=role, + canonical_owner=owner, + canonical_team=team, + ) diff --git a/server/opensandbox_server/repositories/snapshots/sqlite.py b/server/opensandbox_server/repositories/snapshots/sqlite.py index 2fac0e257..1fa899a3f 100644 --- a/server/opensandbox_server/repositories/snapshots/sqlite.py +++ b/server/opensandbox_server/repositories/snapshots/sqlite.py @@ -65,8 +65,10 @@ def create(self, record: SnapshotRecord) -> SnapshotRecord: message, last_transition_at, created_at, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + updated_at, + access_owner, + access_team + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, self._to_db_tuple(record), ) @@ -87,7 +89,9 @@ def get(self, snapshot_id: str) -> SnapshotRecord | None: message, last_transition_at, created_at, - updated_at + updated_at, + access_owner, + access_team FROM snapshots WHERE id = ? """, @@ -103,6 +107,19 @@ def list(self, query: SnapshotListQuery) -> SnapshotListResult: clauses.append("source_sandbox_id = ?") params.append(query.source_sandbox_id) + if query.access_owner is not None: + if query.include_unscoped_owner: + # Include legacy snapshots (NULL access_owner) alongside owned ones + # so records created before scope metadata was introduced remain visible. + clauses.append("(access_owner = ? OR access_owner IS NULL)") + else: + clauses.append("access_owner = ?") + params.append(query.access_owner) + + if query.access_team is not None: + clauses.append("access_team = ?") + params.append(query.access_team) + if query.states: clauses.append( f"state IN ({', '.join('?' for _ in query.states)})" @@ -132,7 +149,9 @@ def list(self, query: SnapshotListQuery) -> SnapshotListResult: message, last_transition_at, created_at, - updated_at + updated_at, + access_owner, + access_team FROM snapshots {where_clause} ORDER BY created_at DESC, id DESC @@ -161,7 +180,9 @@ def update(self, record: SnapshotRecord) -> SnapshotRecord: message = ?, last_transition_at = ?, created_at = ?, - updated_at = ? + updated_at = ?, + access_owner = ?, + access_team = ? WHERE id = ? """, ( @@ -175,6 +196,8 @@ def update(self, record: SnapshotRecord) -> SnapshotRecord: self._datetime_to_str(record.status.last_transition_at), self._datetime_to_str(record.created_at), self._datetime_to_str(record.updated_at), + record.access_owner, + record.access_team, record.id, ), ) @@ -199,7 +222,9 @@ def update_if_state( message = ?, last_transition_at = ?, created_at = ?, - updated_at = ? + updated_at = ?, + access_owner = ?, + access_team = ? WHERE id = ? AND state = ? """, ( @@ -213,6 +238,8 @@ def update_if_state( self._datetime_to_str(record.status.last_transition_at), self._datetime_to_str(record.created_at), self._datetime_to_str(record.updated_at), + record.access_owner, + record.access_team, record.id, expected_state.value, ), @@ -238,7 +265,9 @@ def _initialize_schema(self) -> None: message TEXT, last_transition_at TEXT, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + access_owner TEXT, + access_team TEXT ); CREATE INDEX IF NOT EXISTS idx_snapshots_source_sandbox_id @@ -251,6 +280,15 @@ def _initialize_schema(self) -> None: ON snapshots(created_at DESC); """ ) + self._migrate_add_scope_columns() + + def _migrate_add_scope_columns(self) -> None: + for col_def in ("access_owner TEXT", "access_team TEXT"): + try: + with self._connect() as conn: + conn.execute(f"ALTER TABLE snapshots ADD COLUMN {col_def}") + except Exception: + pass # column already exists def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self._db_path) @@ -272,6 +310,8 @@ def _to_db_tuple(self, record: SnapshotRecord) -> tuple[object, ...]: self._datetime_to_str(record.status.last_transition_at), self._datetime_to_str(record.created_at), self._datetime_to_str(record.updated_at), + record.access_owner, + record.access_team, ) @staticmethod @@ -287,6 +327,7 @@ def _datetime_to_str(value) -> str | None: @staticmethod def _row_to_record(row: sqlite3.Row) -> SnapshotRecord: restore_config = json.loads(row["restore_config"]) + row_keys = row.keys() return SnapshotRecord( id=row["id"], source_sandbox_id=row["source_sandbox_id"], @@ -303,6 +344,8 @@ def _row_to_record(row: sqlite3.Row) -> SnapshotRecord: ), created_at=SQLiteSnapshotRepository._str_to_datetime(row["created_at"]), updated_at=SQLiteSnapshotRepository._str_to_datetime(row["updated_at"]), + access_owner=row["access_owner"] if "access_owner" in row_keys else None, + access_team=row["access_team"] if "access_team" in row_keys else None, ) @staticmethod diff --git a/server/opensandbox_server/services/snapshot_models.py b/server/opensandbox_server/services/snapshot_models.py index d7c5c1960..ac4fc1db9 100644 --- a/server/opensandbox_server/services/snapshot_models.py +++ b/server/opensandbox_server/services/snapshot_models.py @@ -77,6 +77,8 @@ class SnapshotRecord: ) created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) + access_owner: str | None = None + access_team: str | None = None __all__ = [ diff --git a/server/opensandbox_server/services/snapshot_repository.py b/server/opensandbox_server/services/snapshot_repository.py index 69b327b6b..d969ac6bf 100644 --- a/server/opensandbox_server/services/snapshot_repository.py +++ b/server/opensandbox_server/services/snapshot_repository.py @@ -32,6 +32,9 @@ class SnapshotListQuery: page_size: int = 20 source_sandbox_id: str | None = None states: list[str] = field(default_factory=list) + access_owner: str | None = None + access_team: str | None = None + include_unscoped_owner: bool = False @dataclass(slots=True) diff --git a/server/opensandbox_server/services/snapshot_service.py b/server/opensandbox_server/services/snapshot_service.py index 0258d1517..94aa1918f 100644 --- a/server/opensandbox_server/services/snapshot_service.py +++ b/server/opensandbox_server/services/snapshot_service.py @@ -69,11 +69,11 @@ class SnapshotService(ABC): """ @abstractmethod - def create_snapshot(self, sandbox_id: str, request: CreateSnapshotRequest) -> Snapshot: + def create_snapshot(self, sandbox_id: str, request: CreateSnapshotRequest, *, access_owner: str | None = None, access_team: str | None = None) -> Snapshot: pass @abstractmethod - def list_snapshots(self, request: ListSnapshotsRequest) -> ListSnapshotsResponse: + def list_snapshots(self, request: ListSnapshotsRequest, *, access_owner: str | None = None, access_team: str | None = None) -> ListSnapshotsResponse: pass @abstractmethod @@ -114,7 +114,7 @@ def __init__( if recover_unfinished_snapshots: self.recover_unfinished_snapshots() - def create_snapshot(self, sandbox_id: str, request: CreateSnapshotRequest) -> Snapshot: + def create_snapshot(self, sandbox_id: str, request: CreateSnapshotRequest, *, access_owner: str | None = None, access_team: str | None = None) -> Snapshot: sandbox = self._sandbox_service.get_sandbox(sandbox_id) self._ensure_source_sandbox_running(sandbox) @@ -141,6 +141,8 @@ def create_snapshot(self, sandbox_id: str, request: CreateSnapshotRequest) -> Sn ), created_at=now, updated_at=now, + access_owner=access_owner, + access_team=access_team, ) self._snapshot_repository.create(record) future = self._snapshot_executor.submit( @@ -150,7 +152,7 @@ def create_snapshot(self, sandbox_id: str, request: CreateSnapshotRequest) -> Sn future.add_done_callback(self._log_worker_failure) return self._to_snapshot_response(record) - def list_snapshots(self, request: ListSnapshotsRequest) -> ListSnapshotsResponse: + def list_snapshots(self, request: ListSnapshotsRequest, *, access_owner: str | None = None, access_team: str | None = None) -> ListSnapshotsResponse: pagination = request.pagination or self._default_pagination() result = self._snapshot_repository.list( SnapshotListQuery( @@ -158,6 +160,8 @@ def list_snapshots(self, request: ListSnapshotsRequest) -> ListSnapshotsResponse page_size=pagination.page_size, source_sandbox_id=request.filter.sandbox_id, states=request.filter.state or [], + access_owner=access_owner, + access_team=access_team, ) ) @@ -248,6 +252,8 @@ def _mark_snapshot_deleting(self, record: SnapshotRecord) -> SnapshotRecord | No ), created_at=record.created_at, updated_at=now, + access_owner=record.access_owner, + access_team=record.access_team, ) if self._snapshot_repository.update_if_state( deleting_record, @@ -425,6 +431,8 @@ def _build_runtime_status_record( ), created_at=record.created_at, updated_at=now, + access_owner=record.access_owner, + access_team=record.access_team, ) return SnapshotRecord( @@ -441,6 +449,8 @@ def _build_runtime_status_record( ), created_at=record.created_at, updated_at=now, + access_owner=record.access_owner, + access_team=record.access_team, ) if runtime_status.state == SnapshotState.FAILED: @@ -458,6 +468,8 @@ def _build_runtime_status_record( ), created_at=record.created_at, updated_at=now, + access_owner=record.access_owner, + access_team=record.access_team, ) return None @@ -516,6 +528,8 @@ def _to_snapshot_response(record: SnapshotRecord) -> Snapshot: lastTransitionAt=record.status.last_transition_at, ), createdAt=record.created_at, + access_owner=record.access_owner, + access_team=record.access_team, ) diff --git a/server/tests/test_auth_trusted_header.py b/server/tests/test_auth_trusted_header.py new file mode 100644 index 000000000..7670a3436 --- /dev/null +++ b/server/tests/test_auth_trusted_header.py @@ -0,0 +1,129 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from opensandbox_server.config import ( + AUTH_MODE_API_KEY_AND_USER, + AppConfig, + AuthConfig, + AuthzConfig, + IngressConfig, + RuntimeConfig, + ServerConfig, + TrustedHeaderConfig, +) +from opensandbox_server.middleware.auth import AuthMiddleware + + +def _app_dual_auth() -> AppConfig: + return AppConfig( + server=ServerConfig(api_key="api-secret"), + auth=AuthConfig( + mode=AUTH_MODE_API_KEY_AND_USER, + trusted_header=TrustedHeaderConfig( + user_header="X-OpenSandbox-User", + team_header="X-OpenSandbox-Team", + roles_header="X-OpenSandbox-Roles", + ), + ), + authz=AuthzConfig(), + runtime=RuntimeConfig(type="docker", execd_image="opensandbox/execd:latest"), + ingress=IngressConfig(mode="direct"), + ) + + +def test_trusted_user_missing_identity_returns_401(): + app = FastAPI() + app.add_middleware(AuthMiddleware, config=_app_dual_auth()) + + @app.get("/secured") + def secured(): + return {"ok": True} + + c = TestClient(app) + r = c.get("/secured") + assert r.status_code == 401 + assert r.json()["code"] == "MISSING_TRUSTED_IDENTITY" + + +def test_trusted_user_with_user_and_roles_succeeds(): + app = FastAPI() + app.add_middleware(AuthMiddleware, config=_app_dual_auth()) + + @app.get("/who") + def who(request: Request): + p = getattr(request.state, "principal", None) + return {"subject": p.subject, "role": p.role if p else None} + + c = TestClient(app) + r = c.get( + "/who", + headers={ + "X-OpenSandbox-User": "dev-user", + "X-OpenSandbox-Roles": "read_only", + }, + ) + assert r.status_code == 200 + assert r.json()["subject"] == "dev-user" + assert r.json()["role"] == "read_only" + + +def test_trusted_user_with_only_user_header_uses_default_role(): + app = FastAPI() + app.add_middleware(AuthMiddleware, config=_app_dual_auth()) + + @app.get("/who") + def who(request: Request): + p = getattr(request.state, "principal", None) + return {"subject": p.subject, "role": p.role if p else None} + + c = TestClient(app) + r = c.get( + "/who", + headers={ + "X-OpenSandbox-User": "dev-user", + }, + ) + assert r.status_code == 200 + assert r.json()["subject"] == "dev-user" + assert r.json()["role"] == "read_only" + + +def test_valid_api_key_grants_service_admin(): + app = FastAPI() + app.add_middleware(AuthMiddleware, config=_app_dual_auth()) + + @app.get("/role") + def role(request: Request): + p = getattr(request.state, "principal", None) + return {"admin": bool(p and p.is_service_admin)} + + c = TestClient(app) + r = c.get("/role", headers={"OPEN-SANDBOX-API-KEY": "api-secret"}) + assert r.status_code == 200 + assert r.json() == {"admin": True} + + +def test_api_key_mismatch_still_401_with_valid_user_headers(): + app = FastAPI() + app.add_middleware(AuthMiddleware, config=_app_dual_auth()) + + @app.get("/x") + def x(): + return {"n": 1} + + c = TestClient(app) + r = c.get( + "/x", + headers={ + "OPEN-SANDBOX-API-KEY": "wrong", + "X-OpenSandbox-User": "u", + "X-OpenSandbox-Roles": "operator", + }, + ) + assert r.status_code == 401 + assert r.json()["code"] == "INVALID_API_KEY" diff --git a/server/tests/test_authorization.py b/server/tests/test_authorization.py new file mode 100644 index 000000000..3d79f7d2d --- /dev/null +++ b/server/tests/test_authorization.py @@ -0,0 +1,83 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +import pytest +from fastapi import status +from fastapi.exceptions import HTTPException + +from opensandbox_server.api.schema import ImageSpec, Sandbox, SandboxStatus +from opensandbox_server.config import AuthzConfig +from opensandbox_server.middleware.authorization import ( + LifecycleAction, + authorize_action, + sandbox_in_scope, +) +from opensandbox_server.middleware.principal import build_user_principal, principal_for_api_key + + +def _box(owner: str, team: str | None = "t1") -> Sandbox: + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + meta = {"access.owner": owner} + if team is not None: + meta["access.team"] = team + return Sandbox( + id="s1", + image=ImageSpec(uri="x"), + status=SandboxStatus(state="Running"), + metadata=meta, + entrypoint=["sh"], + expiresAt=now + timedelta(hours=1), + createdAt=now, + ) + + +def test_service_admin_bypasses_scope(): + p = principal_for_api_key() + z = AuthzConfig() + assert sandbox_in_scope(p, _box("other"), z.owner_metadata_key, z.team_metadata_key) + + +def test_user_in_scope_owner_and_team(): + z = AuthzConfig() + p = build_user_principal("u1", "t1", "read_only", z) + assert sandbox_in_scope(p, _box(p.canonical_owner, "t1"), z.owner_metadata_key, z.team_metadata_key) + assert not sandbox_in_scope( + p, _box("someone-else", "t1"), z.owner_metadata_key, z.team_metadata_key + ) + + +def test_read_only_cannot_create(): + z = AuthzConfig() + p = build_user_principal("u1", "t1", "read_only", z) + with pytest.raises(HTTPException) as ei: + authorize_action( + p, + LifecycleAction.CREATE, + owner_key=z.owner_metadata_key, + team_key=z.team_metadata_key, + ) + assert ei.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_operator_can_create(): + z = AuthzConfig() + p = build_user_principal("u1", "t1", "operator", z) + authorize_action( + p, + LifecycleAction.CREATE, + owner_key=z.owner_metadata_key, + team_key=z.team_metadata_key, + ) + + +def test_none_principal_allows_mutation_for_legacy_dev(): + authorize_action( + None, + LifecycleAction.CREATE, + owner_key="access.owner", + team_key="access.team", + ) diff --git a/server/tests/test_config.py b/server/tests/test_config.py index 2dd6e6e79..6ea121699 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -1418,3 +1418,19 @@ def test_secure_access_active_key_mismatch(self, tmp_path, monkeypatch) -> None: with pytest.raises(ValidationError, match="not found in secure_access.keys"): config_module.load_config(config_path) +def test_console_mount_path_rejects_reserved_prefixes(): + server_cfg = ServerConfig() + runtime_cfg = RuntimeConfig(type="docker", execd_image="busybox:latest") + for bad in ("/v1", "/v1/", "/v1/admin", "/sandboxes", "/"): + with pytest.raises(ValueError): + AppConfig(server=server_cfg, runtime=runtime_cfg, console={"mount_path": bad}) + # /v1ui has no path conflict — should be accepted + cfg = AppConfig(server=server_cfg, runtime=runtime_cfg, console={"mount_path": "/v1ui"}) + assert cfg.console.mount_path == "/v1ui" + + +def test_console_mount_path_allows_custom_non_api_path(): + server_cfg = ServerConfig() + runtime_cfg = RuntimeConfig(type="docker", execd_image="busybox:latest") + app_cfg = AppConfig(server=server_cfg, runtime=runtime_cfg, console={"mount_path": "/console-ui"}) + assert app_cfg.console.mount_path == "/console-ui" diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index a312b4cc9..ec8dcc9d8 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -1,48 +1,63 @@ -# Copyright 2025 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from datetime import datetime, timezone - -from opensandbox_server.services.helpers import parse_timestamp - - -def test_parse_timestamp_truncates_nanoseconds(): - ts = "2025-12-10T05:29:56.359015208Z" - - result = parse_timestamp(ts) - - assert result.tzinfo is not None - assert result.astimezone(timezone.utc) == result - assert result.year == 2025 - assert result.month == 12 - assert result.day == 10 - assert result.microsecond == 359015 - - -def test_parse_timestamp_parses_valid_rfc3339(): - ts = "2024-01-01T12:34:56.123456Z" - - result = parse_timestamp(ts) - - assert result.tzinfo is not None - assert result == datetime(2024, 1, 1, 12, 34, 56, 123456, tzinfo=timezone.utc) - - -def test_parse_timestamp_invalid_falls_back_to_now(): - before = datetime.now(timezone.utc) - result = parse_timestamp("not-a-time") - after = datetime.now(timezone.utc) - - assert result.tzinfo is not None - assert before <= result <= after +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timedelta, timezone + +from opensandbox_server.api.schema import ImageSpec, Sandbox, SandboxStatus +from opensandbox_server.services.helpers import parse_timestamp + + +def minimal_sandbox(sandbox_id: str = "sbx-001") -> Sandbox: + """Minimal valid Sandbox for stubbing ``get_sandbox`` in route tests.""" + now = datetime.now(timezone.utc) + return Sandbox( + id=sandbox_id, + image=ImageSpec(uri="test:latest"), + status=SandboxStatus(state="Running"), + metadata={}, + entrypoint=["sh"], + expiresAt=now + timedelta(hours=1), + createdAt=now, + ) + + +def test_parse_timestamp_truncates_nanoseconds(): + ts = "2025-12-10T05:29:56.359015208Z" + + result = parse_timestamp(ts) + + assert result.tzinfo is not None + assert result.astimezone(timezone.utc) == result + assert result.year == 2025 + assert result.month == 12 + assert result.day == 10 + assert result.microsecond == 359015 + + +def test_parse_timestamp_parses_valid_rfc3339(): + ts = "2024-01-01T12:34:56.123456Z" + + result = parse_timestamp(ts) + + assert result.tzinfo is not None + assert result == datetime(2024, 1, 1, 12, 34, 56, 123456, tzinfo=timezone.utc) + + +def test_parse_timestamp_invalid_falls_back_to_now(): + before = datetime.now(timezone.utc) + result = parse_timestamp("not-a-time") + after = datetime.now(timezone.utc) + + assert result.tzinfo is not None + assert before <= result <= after diff --git a/server/tests/test_lifecycle_helpers.py b/server/tests/test_lifecycle_helpers.py new file mode 100644 index 000000000..c915ddf51 --- /dev/null +++ b/server/tests/test_lifecycle_helpers.py @@ -0,0 +1,84 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi import status +from fastapi.exceptions import HTTPException + +from opensandbox_server.api.lifecycle_helpers import authorize_mutating_action, merge_list_scope_from_request +from opensandbox_server.api.schema import ListSandboxesRequest, PaginationRequest, SandboxFilter +from opensandbox_server.config import AppConfig, AuthzConfig, IngressConfig, RuntimeConfig, ServerConfig +from opensandbox_server.middleware.authorization import LifecycleAction +from opensandbox_server.middleware.principal import build_user_principal + + +def _min_config() -> AppConfig: + return AppConfig( + server=ServerConfig(), + authz=AuthzConfig( + owner_metadata_key="access.owner", + team_metadata_key="access.team", + ), + runtime=RuntimeConfig(type="docker", execd_image="x"), + ingress=IngressConfig(mode="direct"), + ) + + +def _make_request(principal=None) -> MagicMock: + req = MagicMock() + req.state = SimpleNamespace(principal=principal) + req.headers = {} + return req + + +def test_authorize_mutating_action_logs_and_reraises_on_403(): + from unittest.mock import patch + + z = _min_config() + p = build_user_principal("u1", None, "read_only", z.authz) + req = _make_request(p) + with patch("opensandbox_server.api.lifecycle_helpers.logger") as mock_log: + with pytest.raises(HTTPException) as ei: + authorize_mutating_action( + req, p, LifecycleAction.CREATE, + owner_key=z.authz.owner_metadata_key, + team_key=z.authz.team_metadata_key, + sandbox_id=None, + ) + assert ei.value.status_code == status.HTTP_403_FORBIDDEN + mock_log.info.assert_called_once() + logged_msg = str(mock_log.info.call_args) + assert "forbidden" in logged_msg + + +def test_authorize_mutating_action_passes_for_operator(): + z = _min_config() + p = build_user_principal("u1", None, "operator", z.authz) + req = _make_request(p) + # Should not raise + authorize_mutating_action( + req, p, LifecycleAction.CREATE, + owner_key=z.authz.owner_metadata_key, + team_key=z.authz.team_metadata_key, + ) + + +def test_merge_list_scope_injects_owner_for_user(): + z = _min_config() + p = build_user_principal("alice", "t1", "read_only", z.authz) + list_req = ListSandboxesRequest( + filter=SandboxFilter(state=None, metadata={"k": "v"}), + pagination=PaginationRequest(page=1, pageSize=20), + ) + http_request = MagicMock() + http_request.state = SimpleNamespace(principal=p) + out = merge_list_scope_from_request(http_request, list_req, z) + assert out.filter.metadata + assert out.filter.metadata.get("k") == "v" + assert out.filter.metadata.get("access.owner") == p.canonical_owner + assert out.filter.metadata.get("access.team") == p.canonical_team diff --git a/server/tests/test_principal.py b/server/tests/test_principal.py new file mode 100644 index 000000000..b5eecbf24 --- /dev/null +++ b/server/tests/test_principal.py @@ -0,0 +1,63 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +from opensandbox_server.config import AuthzConfig +from opensandbox_server.middleware.principal import ( + build_user_principal, + canonicalize_scoped_value, + principal_for_api_key, + resolve_effective_role, +) + + +def test_canonicalize_scoped_value_passes_valid_label_unchanged(): + assert canonicalize_scoped_value("ab") == "ab" + assert canonicalize_scoped_value("My-Team_1") == "My-Team_1" + + +def test_canonicalize_scoped_value_deterministic_for_long_or_invalid(): + a = canonicalize_scoped_value("not a valid label value because it has spaces") + b = canonicalize_scoped_value("not a valid label value because it has spaces") + assert a == b + assert len(a) <= 63 + a2 = canonicalize_scoped_value("a" * 200) + b2 = canonicalize_scoped_value("a" * 200) + assert a2 == b2 + + +def test_principal_for_api_key_is_service_admin(): + p = principal_for_api_key() + assert p.is_service_admin + assert p.role == "service_admin" + + +def test_resolve_effective_role_default_read_only(): + z = AuthzConfig() + assert resolve_effective_role("anyone", None, z) == "read_only" + + +def test_resolve_effective_role_operator_subjects(): + z = AuthzConfig(operator_subjects=["alice"], default_role="read_only") + assert resolve_effective_role("alice", None, z) == "operator" + + +def test_resolve_effective_role_roles_header_operator(): + z = AuthzConfig() + assert resolve_effective_role("u", "read_only, operator", z) == "operator" + + +def test_build_user_principal_injects_scope_and_respects_role(): + z = AuthzConfig() + p = build_user_principal("Alice", "t1", "operator", z) + assert p.role == "operator" + assert p.canonical_owner + assert p.canonical_team == "t1" + + +def test_build_user_principal_team_optional(): + z = AuthzConfig() + p = build_user_principal("Bob", None, "read_only", z) + assert p.canonical_team is None + assert p.canonical_owner == "Bob" diff --git a/server/tests/test_routes_authorization.py b/server/tests/test_routes_authorization.py new file mode 100644 index 000000000..aa2863891 --- /dev/null +++ b/server/tests/test_routes_authorization.py @@ -0,0 +1,180 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +from datetime import datetime, timedelta, timezone + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from opensandbox_server.api import lifecycle +from opensandbox_server.api.schema import ( + CreateSandboxResponse, + Endpoint, + ImageSpec, + ListSandboxesResponse, + PaginationInfo, + RenewSandboxExpirationResponse, + Sandbox, + SandboxStatus, +) +from opensandbox_server.config import ( + AUTH_MODE_API_KEY_AND_USER, + AppConfig, + AuthConfig, + AuthzConfig, + IngressConfig, + RuntimeConfig, + ServerConfig, +) +from opensandbox_server.middleware.auth import AuthMiddleware + + +def _cfg() -> AppConfig: + return AppConfig( + server=ServerConfig(api_key="api-secret"), + auth=AuthConfig(mode=AUTH_MODE_API_KEY_AND_USER), + authz=AuthzConfig(default_role="read_only"), + runtime=RuntimeConfig(type="docker", execd_image="opensandbox/execd:latest"), + ingress=IngressConfig(mode="direct"), + ) + + +def _sandbox(owner: str) -> Sandbox: + now = datetime.now(timezone.utc) + return Sandbox( + id="sbx-1", + image=ImageSpec(uri="python:3.11"), + status=SandboxStatus(state="Running"), + metadata={"access.owner": owner}, + entrypoint=["python", "-V"], + expiresAt=now + timedelta(hours=1), + createdAt=now, + ) + + +def _build_app(monkeypatch, service_obj) -> TestClient: + cfg = _cfg() + app = FastAPI() + app.add_middleware(AuthMiddleware, config=cfg) + app.include_router(lifecycle.router, prefix="/v1") + monkeypatch.setattr(lifecycle, "sandbox_service", service_obj) + monkeypatch.setattr(lifecycle, "get_config", lambda: cfg) + return TestClient(app) + + +def _user_headers(role: str) -> dict[str, str]: + return { + "X-OpenSandbox-User": "alice", + "X-OpenSandbox-Roles": role, + } + + +def test_read_only_can_list_get_and_endpoint_but_cannot_mutate(monkeypatch) -> None: + owner = "alice" + + class StubService: + @staticmethod + def list_sandboxes(_request) -> ListSandboxesResponse: + return ListSandboxesResponse( + items=[_sandbox(owner)], + pagination=PaginationInfo( + page=1, + pageSize=20, + totalItems=1, + totalPages=1, + hasNextPage=False, + ), + ) + + @staticmethod + def get_sandbox(_sandbox_id: str) -> Sandbox: + return _sandbox(owner) + + @staticmethod + def get_endpoint(_sandbox_id: str, _port: int, resolve_internal: bool = False, expires=None) -> Endpoint: + return Endpoint(endpoint="127.0.0.1:18080") + + c = _build_app(monkeypatch, StubService()) + + r_list = c.get("/v1/sandboxes", headers=_user_headers("read_only")) + assert r_list.status_code == 200 + r_get = c.get("/v1/sandboxes/sbx-1", headers=_user_headers("read_only")) + assert r_get.status_code == 200 + r_ep = c.get("/v1/sandboxes/sbx-1/endpoints/8080", headers=_user_headers("read_only")) + assert r_ep.status_code == 200 + + r_create = c.post( + "/v1/sandboxes", + headers=_user_headers("read_only"), + json={ + "image": {"uri": "python:3.11"}, + "timeout": 3600, + "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, + "entrypoint": ["python", "-V"], + }, + ) + assert r_create.status_code == 403 + payload = r_create.json() + code = payload.get("code") + if code is None and isinstance(payload.get("detail"), dict): + code = payload["detail"].get("code") + assert code == "INSUFFICIENT_ROLE" + + +def test_operator_can_mutate(monkeypatch) -> None: + owner = "alice" + + class StubService: + @staticmethod + def get_sandbox(_sandbox_id: str) -> Sandbox: + return _sandbox(owner) + + @staticmethod + async def create_sandbox(_request) -> CreateSandboxResponse: + now = datetime.now(timezone.utc) + return CreateSandboxResponse( + id="sbx-2", + status=SandboxStatus(state="Pending"), + metadata={"access.owner": owner}, + expiresAt=now + timedelta(hours=1), + createdAt=now, + entrypoint=["python", "-V"], + ) + + @staticmethod + def delete_sandbox(_sandbox_id: str) -> None: + return None + + @staticmethod + def renew_expiration(_sandbox_id: str, _request) -> RenewSandboxExpirationResponse: + return RenewSandboxExpirationResponse(expiresAt=datetime.now(timezone.utc) + timedelta(hours=2)) + + @staticmethod + def pause_sandbox(_sandbox_id: str) -> None: + return None + + @staticmethod + def resume_sandbox(_sandbox_id: str) -> None: + return None + + c = _build_app(monkeypatch, StubService()) + h = _user_headers("operator") + + r_create = c.post( + "/v1/sandboxes", + headers=h, + json={ + "image": {"uri": "python:3.11"}, + "timeout": 3600, + "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, + "entrypoint": ["python", "-V"], + }, + ) + assert r_create.status_code == 202 + + assert c.post("/v1/sandboxes/sbx-1/renew-expiration", headers=h, json={"expiresAt": "2030-01-01T00:00:00Z"}).status_code == 200 + assert c.post("/v1/sandboxes/sbx-1/pause", headers=h).status_code == 202 + assert c.post("/v1/sandboxes/sbx-1/resume", headers=h).status_code == 202 + assert c.delete("/v1/sandboxes/sbx-1", headers=h).status_code == 204 diff --git a/server/tests/test_routes_create_delete.py b/server/tests/test_routes_create_delete.py index 57c946f10..91d1a9628 100644 --- a/server/tests/test_routes_create_delete.py +++ b/server/tests/test_routes_create_delete.py @@ -1,214 +1,126 @@ -# Copyright 2025 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from datetime import datetime, timedelta, timezone - -from fastapi.testclient import TestClient - -from opensandbox_server.api import lifecycle -from opensandbox_server.api.schema import CreateSandboxResponse, SandboxStatus - - -def test_create_sandbox_returns_202_and_service_payload( - client: TestClient, - auth_headers: dict, - sample_sandbox_request: dict, - monkeypatch, -) -> None: - now = datetime.now(timezone.utc) - calls: list[object] = [] - - class StubService: - @staticmethod - async def create_sandbox(request) -> CreateSandboxResponse: - calls.append(request) - return CreateSandboxResponse( - id="sbx-001", - status=SandboxStatus(state="Pending"), - metadata={"project": "test-project"}, - expiresAt=now + timedelta(hours=1), - createdAt=now, - entrypoint=["python", "-c", "print('Hello from sandbox')"], - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post( - "/v1/sandboxes", - headers=auth_headers, - json=sample_sandbox_request, - ) - - assert response.status_code == 202 - payload = response.json() - assert payload["id"] == "sbx-001" - assert payload["status"]["state"] == "Pending" - assert payload["metadata"]["project"] == "test-project" - assert payload["entrypoint"] == ["python", "-c", "print('Hello from sandbox')"] - assert len(calls) == 1 - assert calls[0].image.uri == "python:3.11" - - -def test_create_sandbox_manual_cleanup_omits_none_fields( - client: TestClient, - auth_headers: dict, - sample_sandbox_request: dict, - monkeypatch, -) -> None: - now = datetime.now(timezone.utc) - - class StubService: - @staticmethod - async def create_sandbox(request) -> CreateSandboxResponse: - return CreateSandboxResponse( - id="sbx-manual", - status=SandboxStatus(state="Pending"), - metadata=None, - expiresAt=None, - createdAt=now, - entrypoint=["python", "-c", "print('Hello from sandbox')"], - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - sample_sandbox_request.pop("timeout", None) - - response = client.post( - "/v1/sandboxes", - headers=auth_headers, - json=sample_sandbox_request, - ) - - assert response.status_code == 202 - payload = response.json() - assert "expiresAt" not in payload - assert "metadata" not in payload - assert "reason" not in payload["status"] - assert "message" not in payload["status"] - assert "lastTransitionAt" not in payload["status"] - - -def test_create_sandbox_rejects_invalid_request( - client: TestClient, - auth_headers: dict, -) -> None: - response = client.post( - "/v1/sandboxes", - headers=auth_headers, - json={"timeout": 10}, - ) - - assert response.status_code == 422 - - -def test_create_sandbox_accepts_snapshot_id_without_entrypoint( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - now = datetime.now(timezone.utc) - calls: list[object] = [] - - class StubService: - @staticmethod - async def create_sandbox(request) -> CreateSandboxResponse: - calls.append(request) - return CreateSandboxResponse( - id="sbx-from-snapshot", - status=SandboxStatus(state="Pending"), - metadata=None, - expiresAt=now + timedelta(hours=1), - createdAt=now, - entrypoint=None, - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post( - "/v1/sandboxes", - headers=auth_headers, - json={ - "snapshotId": "snap-001", - "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, - }, - ) - - assert response.status_code == 202 - assert calls[0].snapshot_id == "snap-001" - assert calls[0].entrypoint is None - - -def test_create_sandbox_accepts_snapshot_id_with_entrypoint( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - now = datetime.now(timezone.utc) - calls: list[object] = [] - - class StubService: - @staticmethod - async def create_sandbox(request) -> CreateSandboxResponse: - calls.append(request) - return CreateSandboxResponse( - id="sbx-from-snapshot", - status=SandboxStatus(state="Pending"), - metadata=None, - expiresAt=now + timedelta(hours=1), - createdAt=now, - entrypoint=["python", "app.py"], - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post( - "/v1/sandboxes", - headers=auth_headers, - json={ - "snapshotId": "snap-001", - "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, - "entrypoint": ["python", "app.py"], - }, - ) - - assert response.status_code == 202 - assert calls[0].snapshot_id == "snap-001" - assert calls[0].entrypoint == ["python", "app.py"] - - -def test_delete_sandbox_returns_204_and_calls_service( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - calls: list[str] = [] - - class StubService: - @staticmethod - def delete_sandbox(sandbox_id: str) -> None: - calls.append(sandbox_id) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.delete("/v1/sandboxes/sbx-001", headers=auth_headers) - - assert response.status_code == 204 - assert response.text == "" - assert calls == ["sbx-001"] - - -def test_delete_sandbox_requires_api_key(client: TestClient) -> None: - response = client.delete("/v1/sandboxes/sbx-001") - - assert response.status_code == 401 - assert response.json()["code"] == "MISSING_API_KEY" +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient + +from opensandbox_server.api import lifecycle +from opensandbox_server.api.schema import CreateSandboxResponse, SandboxStatus +from opensandbox_server.services.constants import SandboxErrorCodes +from tests.test_helpers import minimal_sandbox + + +def test_create_sandbox_returns_202_and_service_payload( + client: TestClient, + auth_headers: dict, + sample_sandbox_request: dict, + monkeypatch, +) -> None: + now = datetime.now(timezone.utc) + calls: list[object] = [] + + class StubService: + @staticmethod + async def create_sandbox(request) -> CreateSandboxResponse: + calls.append(request) + return CreateSandboxResponse( + id="sbx-001", + status=SandboxStatus(state="Pending"), + metadata={"project": "test-project"}, + expiresAt=now + timedelta(hours=1), + createdAt=now, + entrypoint=["python", "-c", "print('Hello from sandbox')"], + ) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.post( + "/v1/sandboxes", + headers=auth_headers, + json=sample_sandbox_request, + ) + + assert response.status_code == 202 + payload = response.json() + assert payload["id"] == "sbx-001" + assert payload["status"]["state"] == "Pending" + assert payload["metadata"]["project"] == "test-project" + assert payload["entrypoint"] == ["python", "-c", "print('Hello from sandbox')"] + assert len(calls) == 1 + assert calls[0].image.uri == "python:3.11" + + +def test_create_sandbox_rejects_invalid_extensions( + client: TestClient, + auth_headers: dict, + sample_sandbox_request: dict, +) -> None: + payload = { + **sample_sandbox_request, + "extensions": {"access.renew.extend.seconds": "not-an-int"}, + } + response = client.post("/v1/sandboxes", headers=auth_headers, json=payload) + + assert response.status_code == 400 + payload = response.json() + code = payload.get("code") + if code is None and isinstance(payload.get("detail"), dict): + code = payload["detail"].get("code") + assert code == SandboxErrorCodes.INVALID_PARAMETER + + +def test_create_sandbox_rejects_invalid_request( + client: TestClient, + auth_headers: dict, +) -> None: + response = client.post( + "/v1/sandboxes", + headers=auth_headers, + json={"timeout": 10}, + ) + + assert response.status_code == 422 + + +def test_delete_sandbox_returns_204_and_calls_service( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + calls: list[str] = [] + + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str): + return minimal_sandbox(sandbox_id) + + @staticmethod + def delete_sandbox(sandbox_id: str) -> None: + calls.append(sandbox_id) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.delete("/v1/sandboxes/sbx-001", headers=auth_headers) + + assert response.status_code == 204 + assert response.text == "" + assert calls == ["sbx-001"] + + +def test_delete_sandbox_requires_api_key(client: TestClient) -> None: + response = client.delete("/v1/sandboxes/sbx-001") + + assert response.status_code == 401 + assert response.json()["code"] == "MISSING_API_KEY" diff --git a/server/tests/test_routes_endpoint_behavior.py b/server/tests/test_routes_endpoint_behavior.py index 6d71c6853..983384b71 100644 --- a/server/tests/test_routes_endpoint_behavior.py +++ b/server/tests/test_routes_endpoint_behavior.py @@ -1,155 +1,86 @@ -# Copyright 2025 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from types import SimpleNamespace - -from fastapi.testclient import TestClient - -from opensandbox_server.api import lifecycle -from opensandbox_server.api.schema import Endpoint - - -def test_get_endpoint_returns_service_result( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - calls: list[tuple[str, int]] = [] - - class StubService: - @staticmethod - def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: - calls.append((sandbox_id, port)) - return Endpoint(endpoint="10.57.1.91:40109/proxy/44772") - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.get( - "/v1/sandboxes/sbx-001/endpoints/44772", - headers=auth_headers, - ) - - assert response.status_code == 200 - assert response.json()["endpoint"] == "10.57.1.91:40109/proxy/44772" - assert calls == [("sbx-001", 44772)] - - -def test_get_endpoint_use_server_proxy_rewrites_url( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - class StubService: - @staticmethod - def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: - return Endpoint(endpoint="10.57.1.91:40109/proxy/44772") - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.get( - "/v1/sandboxes/sbx-001/endpoints/44772", - params={"use_server_proxy": "true"}, - headers=auth_headers, - ) - - assert response.status_code == 200 - assert response.json()["endpoint"] == "testserver/sandboxes/sbx-001/proxy/44772" - - -def test_get_endpoint_use_server_proxy_prefers_server_eip( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - class StubService: - @staticmethod - def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: - return Endpoint(endpoint="10.57.1.91:40109/proxy/44772") - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - monkeypatch.setattr( - lifecycle, - "get_config", - lambda: SimpleNamespace(server=SimpleNamespace(eip="sandbox.example.com/opensandbox/")), - ) - - response = client.get( - "/v1/sandboxes/sbx-001/endpoints/44772", - params={"use_server_proxy": "true"}, - headers=auth_headers, - ) - - assert response.status_code == 200 - assert response.json()["endpoint"] == "sandbox.example.com/opensandbox/sandboxes/sbx-001/proxy/44772" - - -def test_get_endpoint_rejects_non_numeric_port( - client: TestClient, - auth_headers: dict, -) -> None: - response = client.get( - "/v1/sandboxes/sbx-001/endpoints/not-a-port", - headers=auth_headers, - ) - - assert response.status_code == 422 - - -def test_get_endpoint_passes_expires_to_service( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - captured: dict = {} - - class StubService: - @staticmethod - def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: - captured.update({"sandbox_id": sandbox_id, "port": port, **kwargs}) - return Endpoint(endpoint="sandbox.example.com") - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.get( - "/v1/sandboxes/sbx-001/endpoints/44772", - params={"expires": "2000000000"}, - headers=auth_headers, - ) - - assert response.status_code == 200 - assert captured.get("expires") == 2000000000 - - -def test_get_endpoint_unsigned_when_expires_omitted( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - captured: dict = {} - - class StubService: - @staticmethod - def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: - captured.update(kwargs) - return Endpoint(endpoint="sandbox.example.com") - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.get( - "/v1/sandboxes/sbx-001/endpoints/44772", - headers=auth_headers, - ) - - assert response.status_code == 200 - assert captured.get("expires") is None +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from fastapi.testclient import TestClient + +from opensandbox_server.api import lifecycle +from opensandbox_server.api.schema import Endpoint +from tests.test_helpers import minimal_sandbox + + +def test_get_endpoint_returns_service_result( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + calls: list[tuple[str, int]] = [] + + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str): + return minimal_sandbox(sandbox_id) + + @staticmethod + def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: + calls.append((sandbox_id, port)) + return Endpoint(endpoint="10.57.1.91:40109/proxy/44772") + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.get( + "/v1/sandboxes/sbx-001/endpoints/44772", + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.json()["endpoint"] == "10.57.1.91:40109/proxy/44772" + assert calls == [("sbx-001", 44772)] + + +def test_get_endpoint_use_server_proxy_rewrites_url( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str): + return minimal_sandbox(sandbox_id) + + @staticmethod + def get_endpoint(sandbox_id: str, port: int, **kwargs) -> Endpoint: + return Endpoint(endpoint="10.57.1.91:40109/proxy/44772") + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.get( + "/v1/sandboxes/sbx-001/endpoints/44772", + params={"use_server_proxy": "true"}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.json()["endpoint"] == "testserver/sandboxes/sbx-001/proxy/44772" + + +def test_get_endpoint_rejects_non_numeric_port( + client: TestClient, + auth_headers: dict, +) -> None: + response = client.get( + "/v1/sandboxes/sbx-001/endpoints/not-a-port", + headers=auth_headers, + ) + + assert response.status_code == 422 diff --git a/server/tests/test_routes_patch_metadata.py b/server/tests/test_routes_patch_metadata.py index 41821a357..8b3a8c7c6 100644 --- a/server/tests/test_routes_patch_metadata.py +++ b/server/tests/test_routes_patch_metadata.py @@ -35,12 +35,21 @@ def _make_sandbox(metadata: Optional[Dict[str, str]] = None) -> Sandbox: ) +def _stub_get_sandbox(sandbox_id: str): + from opensandbox_server.api.schema import ImageSpec, SandboxStatus + from datetime import datetime, timedelta, timezone + now = datetime.now(timezone.utc) + return {"id": sandbox_id, "metadata": {}} + + class TestPatchMetadataRoute: def test_add_keys(self, client: TestClient, auth_headers: dict, monkeypatch) -> None: sandbox = _make_sandbox({"team": "old"}) class StubService: + get_sandbox = staticmethod(_stub_get_sandbox) + @staticmethod def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: assert sandbox_id == "sbx-001" @@ -60,6 +69,8 @@ def test_delete_key(self, client: TestClient, auth_headers: dict, monkeypatch) - sandbox = _make_sandbox({"team": "platform"}) class StubService: + get_sandbox = staticmethod(_stub_get_sandbox) + @staticmethod def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: assert patch == {"deprecated-key": None} @@ -78,6 +89,8 @@ def test_mixed_operations(self, client: TestClient, auth_headers: dict, monkeypa sandbox = _make_sandbox() class StubService: + get_sandbox = staticmethod(_stub_get_sandbox) + @staticmethod def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: assert patch == {"project": "new", "team": None, "env": "production"} @@ -96,6 +109,8 @@ def test_empty_body_noop(self, client: TestClient, auth_headers: dict, monkeypat sandbox = _make_sandbox({"team": "platform"}) class StubService: + get_sandbox = staticmethod(_stub_get_sandbox) + @staticmethod def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: assert patch == {} @@ -112,6 +127,13 @@ def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: def test_not_found(self, client: TestClient, auth_headers: dict, monkeypatch) -> None: class StubService: + @staticmethod + def get_sandbox(sandbox_id: str): + raise HTTPException( + status_code=404, + detail={"code": "SANDBOX_NOT_FOUND", "message": f"Sandbox {sandbox_id} not found"}, + ) + @staticmethod def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: raise HTTPException( @@ -130,6 +152,8 @@ def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: def test_invalid_metadata_rejected(self, client: TestClient, auth_headers: dict, monkeypatch) -> None: class StubService: + get_sandbox = staticmethod(_stub_get_sandbox) + @staticmethod def patch_sandbox_metadata(sandbox_id: str, patch: dict) -> Sandbox: raise HTTPException( diff --git a/server/tests/test_routes_pause_resume.py b/server/tests/test_routes_pause_resume.py index db3359359..b5071bdb8 100644 --- a/server/tests/test_routes_pause_resume.py +++ b/server/tests/test_routes_pause_resume.py @@ -1,89 +1,120 @@ -# Copyright 2025 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from fastapi.exceptions import HTTPException -from fastapi.testclient import TestClient - -from opensandbox_server.api import lifecycle - - -def test_pause_route_calls_service_and_returns_202( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - calls: list[str] = [] - - class StubService: - @staticmethod - def pause_sandbox(sandbox_id: str) -> None: - calls.append(sandbox_id) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post("/v1/sandboxes/sbx-001/pause", headers=auth_headers) - - assert response.status_code == 202 - assert calls == ["sbx-001"] - - -def test_resume_route_calls_service_and_returns_202( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - calls: list[str] = [] - - class StubService: - @staticmethod - def resume_sandbox(sandbox_id: str) -> None: - calls.append(sandbox_id) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post("/v1/sandboxes/sbx-001/resume", headers=auth_headers) - - assert response.status_code == 202 - assert calls == ["sbx-001"] - - -def test_pause_route_propagates_service_http_error( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - class StubService: - @staticmethod - def pause_sandbox(sandbox_id: str) -> None: - raise HTTPException( - status_code=404, - detail={"code": "SANDBOX_NOT_FOUND", "message": f"Sandbox {sandbox_id} not found"}, - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post("/v1/sandboxes/missing/pause", headers=auth_headers) - - assert response.status_code == 404 - assert response.json() == { - "code": "SANDBOX_NOT_FOUND", - "message": "Sandbox missing not found", - } - - -def test_pause_route_requires_api_key(client: TestClient) -> None: - response = client.post("/v1/sandboxes/sbx-001/pause") - - assert response.status_code == 401 - assert response.json()["code"] == "MISSING_API_KEY" +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timedelta, timezone + +from fastapi import status +from fastapi.exceptions import HTTPException +from fastapi.testclient import TestClient + +from opensandbox_server.api import lifecycle +from opensandbox_server.api.schema import ImageSpec, Sandbox, SandboxStatus +from tests.test_helpers import minimal_sandbox + + +def test_pause_route_calls_service_and_returns_202( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + calls: list[str] = [] + + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str) -> Sandbox: + return minimal_sandbox(sandbox_id) + + @staticmethod + def pause_sandbox(sandbox_id: str) -> None: + calls.append(sandbox_id) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.post("/v1/sandboxes/sbx-001/pause", headers=auth_headers) + + assert response.status_code == 202 + assert calls == ["sbx-001"] + + +def test_resume_route_calls_service_and_returns_202( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + calls: list[str] = [] + + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str) -> Sandbox: + return minimal_sandbox(sandbox_id) + + @staticmethod + def resume_sandbox(sandbox_id: str) -> None: + calls.append(sandbox_id) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.post("/v1/sandboxes/sbx-001/resume", headers=auth_headers) + + assert response.status_code == 202 + assert calls == ["sbx-001"] + + +def test_pause_route_propagates_service_http_error( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str) -> Sandbox: + if sandbox_id == "missing": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "SANDBOX_NOT_FOUND", + "message": f"Sandbox {sandbox_id} not found", + }, + ) + now = datetime.now(timezone.utc) + return Sandbox( + id=sandbox_id, + image=ImageSpec(uri="t"), + status=SandboxStatus(state="Running"), + metadata={}, + entrypoint=["sh"], + expiresAt=now + timedelta(hours=1), + createdAt=now, + ) + + @staticmethod + def pause_sandbox(sandbox_id: str) -> None: + raise AssertionError("pause_sandbox should not be called when get_sandbox fails") + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.post("/v1/sandboxes/missing/pause", headers=auth_headers) + + assert response.status_code == 404 + assert response.json() == { + "code": "SANDBOX_NOT_FOUND", + "message": "Sandbox missing not found", + } + + +def test_pause_route_requires_api_key(client: TestClient) -> None: + response = client.post("/v1/sandboxes/sbx-001/pause") + + assert response.status_code == 401 + assert response.json()["code"] == "MISSING_API_KEY" diff --git a/server/tests/test_routes_renew_expiration.py b/server/tests/test_routes_renew_expiration.py index c8cdbc300..b971ef028 100644 --- a/server/tests/test_routes_renew_expiration.py +++ b/server/tests/test_routes_renew_expiration.py @@ -1,134 +1,112 @@ -# Copyright 2025 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from datetime import datetime, timedelta, timezone - -from fastapi.exceptions import HTTPException -from fastapi.testclient import TestClient - -from opensandbox_server.api import lifecycle -from opensandbox_server.api.schema import RenewSandboxExpirationResponse - - -def test_renew_expiration_returns_updated_timestamp( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - target = datetime.now(timezone.utc) + timedelta(hours=2) - calls: list[tuple[str, datetime]] = [] - - class StubService: - @staticmethod - def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse: - calls.append((sandbox_id, request.expires_at)) - return RenewSandboxExpirationResponse(expiresAt=target) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post( - "/v1/sandboxes/sbx-001/renew-expiration", - headers=auth_headers, - json={"expiresAt": target.isoformat()}, - ) - - assert response.status_code == 200 - expires_at = datetime.fromisoformat(response.json()["expiresAt"].replace("Z", "+00:00")) - assert expires_at == target - assert calls == [("sbx-001", target)] - - -def test_renew_expiration_rejects_invalid_payload( - client: TestClient, - auth_headers: dict, -) -> None: - response = client.post( - "/v1/sandboxes/sbx-001/renew-expiration", - headers=auth_headers, - json={"expiresAt": "not-a-datetime"}, - ) - - assert response.status_code == 422 - - -def test_renew_expiration_propagates_service_http_error( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - class StubService: - @staticmethod - def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse: - raise HTTPException( - status_code=409, - detail={ - "code": "INVALID_EXPIRES_AT", - "message": f"Requested expiresAt is not valid for sandbox {sandbox_id}", - }, - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post( - "/v1/sandboxes/sbx-001/renew-expiration", - headers=auth_headers, - json={"expiresAt": "2030-01-01T00:00:00Z"}, - ) - - assert response.status_code == 409 - assert response.json() == { - "code": "INVALID_EXPIRES_AT", - "message": "Requested expiresAt is not valid for sandbox sbx-001", - } - - -def test_renew_expiration_returns_409_for_manual_cleanup_sandbox( - client: TestClient, - auth_headers: dict, - monkeypatch, -) -> None: - class StubService: - @staticmethod - def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse: - raise HTTPException( - status_code=409, - detail={ - "code": "DOCKER::INVALID_EXPIRATION", - "message": f"Sandbox {sandbox_id} does not have automatic expiration enabled.", - }, - ) - - monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) - - response = client.post( - "/v1/sandboxes/sbx-manual/renew-expiration", - headers=auth_headers, - json={"expiresAt": "2030-01-01T00:00:00Z"}, - ) - - assert response.status_code == 409 - assert response.json() == { - "code": "DOCKER::INVALID_EXPIRATION", - "message": "Sandbox sbx-manual does not have automatic expiration enabled.", - } - - -def test_renew_expiration_requires_api_key(client: TestClient) -> None: - response = client.post( - "/v1/sandboxes/sbx-001/renew-expiration", - json={"expiresAt": "2030-01-01T00:00:00Z"}, - ) - - assert response.status_code == 401 - assert response.json()["code"] == "MISSING_API_KEY" +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timedelta, timezone + +from fastapi.exceptions import HTTPException +from fastapi.testclient import TestClient + +from opensandbox_server.api import lifecycle +from opensandbox_server.api.schema import RenewSandboxExpirationResponse +from tests.test_helpers import minimal_sandbox + + +def test_renew_expiration_returns_updated_timestamp( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + target = datetime.now(timezone.utc) + timedelta(hours=2) + calls: list[tuple[str, datetime]] = [] + + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str): + return minimal_sandbox(sandbox_id) + + @staticmethod + def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse: + calls.append((sandbox_id, request.expires_at)) + return RenewSandboxExpirationResponse(expiresAt=target) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.post( + "/v1/sandboxes/sbx-001/renew-expiration", + headers=auth_headers, + json={"expiresAt": target.isoformat()}, + ) + + assert response.status_code == 200 + expires_at = datetime.fromisoformat(response.json()["expiresAt"].replace("Z", "+00:00")) + assert expires_at == target + assert calls == [("sbx-001", target)] + + +def test_renew_expiration_rejects_invalid_payload( + client: TestClient, + auth_headers: dict, +) -> None: + response = client.post( + "/v1/sandboxes/sbx-001/renew-expiration", + headers=auth_headers, + json={"expiresAt": "not-a-datetime"}, + ) + + assert response.status_code == 422 + + +def test_renew_expiration_propagates_service_http_error( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str): + return minimal_sandbox(sandbox_id) + + @staticmethod + def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse: + raise HTTPException( + status_code=409, + detail={ + "code": "INVALID_EXPIRES_AT", + "message": f"Requested expiresAt is not valid for sandbox {sandbox_id}", + }, + ) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.post( + "/v1/sandboxes/sbx-001/renew-expiration", + headers=auth_headers, + json={"expiresAt": "2030-01-01T00:00:00Z"}, + ) + + assert response.status_code == 409 + assert response.json() == { + "code": "INVALID_EXPIRES_AT", + "message": "Requested expiresAt is not valid for sandbox sbx-001", + } + + +def test_renew_expiration_requires_api_key(client: TestClient) -> None: + response = client.post( + "/v1/sandboxes/sbx-001/renew-expiration", + json={"expiresAt": "2030-01-01T00:00:00Z"}, + ) + + assert response.status_code == 401 + assert response.json()["code"] == "MISSING_API_KEY" diff --git a/server/tests/test_routes_snapshots.py b/server/tests/test_routes_snapshots.py index 77dccccd5..f95103c33 100644 --- a/server/tests/test_routes_snapshots.py +++ b/server/tests/test_routes_snapshots.py @@ -29,6 +29,17 @@ from opensandbox_server.services.snapshot_service import PersistedSnapshotService +def _stub_sandbox_service(): + """Returns a minimal sandbox_service stub that satisfies scope-checking in create_snapshot.""" + + class _Stub: + @staticmethod + def get_sandbox(_sandbox_id: str): + return {"id": _sandbox_id, "metadata": {}} + + return _Stub() + + def _sample_snapshot(now: datetime, snapshot_id: str = "snap-001") -> Snapshot: return Snapshot( id=snapshot_id, @@ -49,11 +60,12 @@ def test_create_snapshot_returns_202_and_location_header( class StubService: @staticmethod - def create_snapshot(sandbox_id: str, request) -> Snapshot: + def create_snapshot(sandbox_id: str, request, *, access_owner=None, access_team=None) -> Snapshot: calls.append((sandbox_id, request)) return _sample_snapshot(now) monkeypatch.setattr(lifecycle, "snapshot_service", StubService()) + monkeypatch.setattr(lifecycle, "sandbox_service", _stub_sandbox_service()) response = client.post( "/v1/sandboxes/sbx-001/snapshots", @@ -77,12 +89,13 @@ def test_create_snapshot_accepts_empty_body( class StubService: @staticmethod - def create_snapshot(sandbox_id: str, request) -> Snapshot: + def create_snapshot(sandbox_id: str, request, *, access_owner=None, access_team=None) -> Snapshot: assert sandbox_id == "sbx-001" assert request.name is None return _sample_snapshot(now) monkeypatch.setattr(lifecycle, "snapshot_service", StubService()) + monkeypatch.setattr(lifecycle, "sandbox_service", _stub_sandbox_service()) response = client.post("/v1/sandboxes/sbx-001/snapshots", headers=auth_headers) @@ -99,7 +112,7 @@ def test_list_snapshots_parses_filters_and_pagination( class StubService: @staticmethod - def list_snapshots(request) -> ListSnapshotsResponse: + def list_snapshots(request, *, access_owner=None, access_team=None) -> ListSnapshotsResponse: captured_requests.append(request) return ListSnapshotsResponse( items=[_sample_snapshot(now)], @@ -236,6 +249,7 @@ def delete_snapshot(snapshot_id: str, image: str | None = None) -> None: snapshot_runtime=StubSnapshotRuntime(), ) monkeypatch.setattr(lifecycle, "snapshot_service", service) + monkeypatch.setattr(lifecycle, "sandbox_service", _stub_sandbox_service()) created = client.post("/v1/sandboxes/sbx-001/snapshots", headers=auth_headers) assert created.status_code == 202 @@ -262,6 +276,7 @@ def get_sandbox(sandbox_id: str): snapshot_runtime=NoopSnapshotRuntime(), ) monkeypatch.setattr(lifecycle, "snapshot_service", service) + monkeypatch.setattr(lifecycle, "sandbox_service", _stub_sandbox_service()) response = client.post("/v1/sandboxes/sbx-001/snapshots", headers=auth_headers) diff --git a/server/tests/test_snapshot_repository_sqlite.py b/server/tests/test_snapshot_repository_sqlite.py index aaf9ed19e..ddb3bf013 100644 --- a/server/tests/test_snapshot_repository_sqlite.py +++ b/server/tests/test_snapshot_repository_sqlite.py @@ -34,6 +34,9 @@ def _record( sandbox_id: str, created_at: datetime, state: SnapshotState = SnapshotState.CREATING, + *, + access_owner: str | None = None, + access_team: str | None = None, ) -> SnapshotRecord: return SnapshotRecord( id=snapshot_id, @@ -51,12 +54,20 @@ def _record( ), created_at=created_at, updated_at=created_at, + access_owner=access_owner, + access_team=access_team, ) def test_sqlite_snapshot_repository_persists_and_fetches_records(tmp_path) -> None: repo = SQLiteSnapshotRepository(tmp_path / "snapshots.db") - record = _record("snap-001", "sbx-001", datetime.utcnow()) + record = _record( + "snap-001", + "sbx-001", + datetime.utcnow(), + access_owner="user-001", + access_team="team-001", + ) repo.create(record) loaded = repo.get("snap-001") @@ -66,6 +77,8 @@ def test_sqlite_snapshot_repository_persists_and_fetches_records(tmp_path) -> No assert loaded.source_sandbox_id == "sbx-001" assert loaded.restore_config.image == record.restore_config.image assert loaded.status.state == SnapshotState.CREATING + assert loaded.access_owner == "user-001" + assert loaded.access_team == "team-001" def test_sqlite_snapshot_repository_enables_wal_and_busy_timeout(tmp_path) -> None: @@ -83,7 +96,14 @@ def test_sqlite_snapshot_repository_lists_and_updates_records(tmp_path) -> None: repo = SQLiteSnapshotRepository(tmp_path / "snapshots.db") now = datetime.utcnow() first = _record("snap-001", "sbx-001", now) - second = _record("snap-002", "sbx-001", now + timedelta(seconds=1), state=SnapshotState.READY) + second = _record( + "snap-002", + "sbx-001", + now + timedelta(seconds=1), + state=SnapshotState.READY, + access_owner="user-001", + access_team="team-001", + ) third = _record("snap-003", "sbx-002", now + timedelta(seconds=2), state=SnapshotState.FAILED) repo.create(first) @@ -96,6 +116,8 @@ def test_sqlite_snapshot_repository_lists_and_updates_records(tmp_path) -> None: assert page.total_items == 1 assert [item.id for item in page.items] == ["snap-002"] + assert page.items[0].access_owner == "user-001" + assert page.items[0].access_team == "team-001" updated = SnapshotRecord( id=first.id, diff --git a/server/tests/test_snapshot_service.py b/server/tests/test_snapshot_service.py index dcb4f2e33..e19dfdbde 100644 --- a/server/tests/test_snapshot_service.py +++ b/server/tests/test_snapshot_service.py @@ -115,12 +115,16 @@ def _snapshot_record( state: SnapshotState, *, image: str | None = None, + access_owner: str | None = None, + access_team: str | None = None, ) -> SnapshotRecord: return SnapshotRecord( id=snapshot_id, source_sandbox_id="sbx-001", restore_config=SnapshotRestoreConfig(image=image), status=SnapshotStatusRecord(state=state), + access_owner=access_owner, + access_team=access_team, ) @@ -410,6 +414,8 @@ def test_snapshot_service_propagates_snapshot_delete_conflict(tmp_path) -> None: "snap-in-use", SnapshotState.READY, image="opensandbox-snapshots:snap-in-use", + access_owner="user-001", + access_team="team-001", ) repo.create(record) @@ -431,6 +437,8 @@ def delete_snapshot(snapshot_id: str, image: str | None = None) -> None: assert exc_info.value.status_code == 409 assert stored is not None assert stored.status.state == SnapshotState.DELETING + assert stored.access_owner == "user-001" + assert stored.access_team == "team-001" def test_snapshot_service_recovers_delete_after_runtime_cleanup_succeeds(tmp_path) -> None: diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index 9809d7d99..92262acfa 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -1,1588 +1,1612 @@ -openapi: 3.1.0 -info: - title: OpenSandbox Lifecycle API - version: 0.1.0 - description: | - The Sandbox Lifecycle API coordinates how untrusted workloads are created, - executed, paused, resumed, and finally disposed. This specification focuses on - the primary lifecycle flows for the `sandbox` domain concept. Sandboxes can - be provisioned directly from container images or restored from snapshots. - - ## Sandbox Lifecycle - - A sandbox follows this lifecycle: - - 1. **Creation** → Sandbox enters `Pending` state (auto-starts) - 2. **Execution** → Transitions to `Running` state - 3. **Pause** (optional) → `Pausing` → `Paused` (asynchronous process) - 4. **Resume** (optional) → `Resuming` → `Running` (asynchronous process) - 5. **Termination** → `Stopping` → `Terminated` (can be triggered by kill action, TTL expiry, or error) - 6. **Error** → Any state can transition to `Failed` on critical errors - - The `status` field provides fine-grained details through `state`, `reason`, and `message`. - - ## Authentication - - API Key authentication is required for all operations: - - 1. **HTTP Header** - ``` - OPEN-SANDBOX-API-KEY: your-api-key - ``` - - 2. **Environment Variable** (for SDK clients) - ``` - OPEN_SANDBOX_API_KEY=your-api-key - ``` - - SDK clients will automatically pick up this environment variable. -servers: - - url: http://localhost:8080/v1 - description: Local development -security: - - apiKeyAuth: [] -tags: - - name: Sandboxes - description: Provision and transition sandboxes through their lifecycle - - name: Snapshots - description: Create, list, and delete persistent sandbox snapshots -paths: - /sandboxes: - get: - tags: [ Sandboxes ] - summary: List sandboxes - description: | - List all sandboxes with optional filtering and pagination using query parameters. - All filter conditions use AND logic. Multiple `state` parameters use OR logic within states. - parameters: - - name: state - in: query - description: | - Filter by lifecycle state. Pass multiple times for OR logic. - Example: `?state=Running&state=Paused` - schema: - type: array - items: - type: string - style: form - explode: true - - name: metadata - in: query - description: | - Arbitrary metadata key-value pairs for filtering,keys and values must be url encoded - Example: To filter by `project=Apollo` and `note=Demo Test`: `?metadata=project%3DApollo%26note%3DDemo%252520Test` - schema: - type: string - style: form - - name: page - in: query - description: Page number for pagination - schema: - type: integer - minimum: 1 - default: 1 - - name: pageSize - in: query - description: Number of items per page - schema: - type: integer - minimum: 1 - default: 20 - responses: - '200': - description: Paginated collection of sandboxes - content: - application/json: - schema: - $ref: '#/components/schemas/ListSandboxesResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalServerError' - post: - tags: [Sandboxes] - summary: Create a sandbox - description: | - Creates a new sandbox from a container image or restores one from a - persistent sandbox snapshot with optional resource limits, environment - variables, and metadata. - - Exactly one startup source must be provided: - - `image` to provision directly from a container image. - - `snapshotId` to restore from a previously created snapshot. - - When `image` is provided, `entrypoint` is required. When `snapshotId` is - provided, `entrypoint` is optional. If omitted, the server defaults the - sandbox entrypoint to `["tail", "-f", "/dev/null"]`. - - ## Authentication - - API Key authentication is required via: - - `OPEN-SANDBOX-API-KEY: ` header - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSandboxRequest' - examples: - deny-with-allowlist: - summary: Deny by default with allowed domains - value: - image: - uri: python:3.11 - timeout: 3600 - resourceLimits: - cpu: "500m" - memory: "512Mi" - entrypoint: ["python", "/app/main.py"] - networkPolicy: - defaultAction: deny - egress: - - action: allow - target: "pypi.org" - allow-with-denylist: - summary: Allow by default with a deny rule - value: - image: - uri: python:3.11 - timeout: 3600 - resourceLimits: - cpu: "500m" - memory: "512Mi" - entrypoint: ["python", "/app/main.py"] - networkPolicy: - defaultAction: allow - egress: - - action: deny - target: "bad.example.com" - manual-cleanup: - summary: Manual cleanup without automatic expiration - value: - image: - uri: python:3.11 - platform: - os: linux - arch: amd64 - resourceLimits: - cpu: "500m" - memory: "512Mi" - entrypoint: ["python", "/app/main.py"] - secure-access: - summary: Enable secured access for sandbox endpoints in Kubernetes gateway mode - value: - image: - uri: python:3.11 - timeout: 3600 - resourceLimits: - cpu: "500m" - memory: "512Mi" - entrypoint: ["python", "/app/main.py"] - secureAccess: true - restore-snapshot: - summary: Restore from a snapshot - value: - snapshotId: snap_123 - timeout: 3600 - resourceLimits: - cpu: "500m" - memory: "512Mi" - restore-snapshot-with-entrypoint: - summary: Restore from a snapshot with a custom entrypoint - value: - snapshotId: snap_123 - timeout: 3600 - resourceLimits: - cpu: "500m" - memory: "512Mi" - entrypoint: ["python", "/workspace/app.py"] - responses: - '202': - description: | - Sandbox created and accepted for provisioning. - - The returned sandbox includes: - - `id`: Unique sandbox identifier - - `status.state: "Pending"` (auto-starting provisioning or restore) - - `status.reason` and `status.message` indicating initialization stage - - `metadata`, `expiresAt`, `createdAt`: Core sandbox information - - Note: startup source details and `updatedAt` are not included in the create response. - Use GET /sandboxes/{sandboxId} to retrieve the complete sandbox information. - - To track provisioning progress, poll GET /sandboxes/{sandboxId}. - The sandbox will automatically transition to `Running` state once provisioning or restore completes. - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSandboxResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - Location: - $ref: '#/components/headers/Location' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /snapshots: - get: - tags: [Snapshots] - summary: List snapshots - description: | - List all snapshots with optional filtering and pagination using query parameters. - Snapshots are persistent captures of sandbox state and may outlive the source sandbox. - parameters: - - name: sandboxId - in: query - description: Filter snapshots by source sandbox identifier - schema: - type: string - - name: state - in: query - description: | - Filter by snapshot lifecycle state. Pass multiple times for OR logic. - Example: `?state=Ready&state=Failed` - schema: - type: array - items: - $ref: '#/components/schemas/SnapshotState' - style: form - explode: true - - name: page - in: query - description: Page number for pagination - schema: - type: integer - minimum: 1 - default: 1 - - name: pageSize - in: query - description: Number of items per page - schema: - type: integer - minimum: 1 - default: 20 - responses: - '200': - description: Paginated collection of snapshots - content: - application/json: - schema: - $ref: '#/components/schemas/ListSnapshotsResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalServerError' - /snapshots/{snapshotId}: - parameters: - - $ref: '#/components/parameters/SnapshotId' - get: - tags: [Snapshots] - summary: Fetch a snapshot by id - description: Returns snapshot state and metadata. - responses: - '200': - description: Snapshot current state and metadata - content: - application/json: - schema: - $ref: '#/components/schemas/Snapshot' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - delete: - tags: [Snapshots] - summary: Delete a snapshot - description: | - Delete a persistent sandbox snapshot by id. Snapshots that are still - being created cannot be deleted. - - For Kubernetes-backed snapshots, deletion removes OpenSandbox metadata - and Kubernetes coordination resources, but does not guarantee removal - of pushed OCI images from the configured registry. Use registry - retention or garbage collection policies for image lifecycle cleanup. - responses: - '204': - description: Snapshot successfully deleted - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}: - parameters: - - $ref: '#/components/parameters/SandboxId' - get: - tags: [Sandboxes] - summary: Fetch a sandbox by id - description: | - Returns the complete sandbox information including: - - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information - - `image` or `snapshotId`: Startup source information (not included in create response) - - `entrypoint`: Entry process specification - - This is the complete representation of the sandbox resource. - responses: - '200': - description: Sandbox current state and metadata - content: - application/json: - schema: - $ref: '#/components/schemas/Sandbox' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - delete: - tags: [Sandboxes] - summary: Delete a sandbox - description: Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to Terminated. - responses: - '204': - description: | - Sandbox successfully deleted. - - Sandbox has been scheduled for termination and will transition to Stopping state, then Terminated. - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}/metadata: - parameters: - - $ref: '#/components/parameters/SandboxId' - patch: - tags: [Sandboxes] - summary: Patch sandbox metadata - description: | - Update sandbox metadata using JSON Merge Patch semantics (RFC 7396). - - **Merge Patch rules:** - | Request body key/value | Behavior | - |---|---| - | `"key": "value"` | Add or replace the key | - | `"key": null` | Delete the key (silently ignored if key does not exist) | - | key absent | Keep current value (no change) | - | Empty `{}` | No-op, returns current metadata | - - Metadata keys and values must comply with Kubernetes label rules: - - Keys must be valid DNS label names or prefixed DNS subdomains - - Keys with the `opensandbox.io/` prefix are reserved and rejected - - Values must be 63 characters or less, matching `[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?` - - This operation does not restart or recreate the sandbox container/pod. - - **Concurrency:** This endpoint uses read-modify-write without optimistic - locking (no `resourceVersion` check). Concurrent PATCH requests may - interleave and silently drop updates. Use a single writer or coordinate - out-of-band when concurrent modifications to the same key are expected. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PatchSandboxMetadataRequest' - examples: - add-and-replace: - summary: Add new keys and replace existing values - value: - team: "platform" - version: "2.0" - delete-key: - summary: Delete a metadata key - value: - deprecated-key: null - mixed-operations: - summary: Add, replace, and delete in a single request - value: - project: "new-project" - team: null - environment: "production" - empty-body: - summary: No-op (returns current metadata) - value: {} - responses: - '200': - description: | - Metadata patched successfully. Returns the complete sandbox resource - with updated metadata. - content: - application/json: - schema: - $ref: '#/components/schemas/Sandbox' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}/snapshots: - post: - tags: [Snapshots] - summary: Create a snapshot from a sandbox - description: | - Create a persistent point-in-time snapshot from the sandbox's current state. - The source sandbox must be `Running`. The returned snapshot id identifies - the created artifact. Snapshot creation may temporarily pause the sandbox - while the runtime captures provider-supported state, then the source - sandbox continues running. - parameters: - - $ref: '#/components/parameters/SandboxId' - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSnapshotRequest' - examples: - default: - summary: Create an unnamed snapshot - value: {} - named: - summary: Create a named snapshot - value: - name: checkpoint-before-import - responses: - '202': - description: | - Snapshot creation accepted. - - The returned snapshot includes `status.state: "Creating"`. - Poll GET /snapshots/{snapshotId} to track progress until the snapshot - transitions to `Ready` or `Failed`. - content: - application/json: - schema: - $ref: '#/components/schemas/Snapshot' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - Location: - $ref: '#/components/headers/Location' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}/pause: - post: - tags: [Sandboxes] - summary: Pause execution while retaining state - description: Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state transition through Pausing and eventually Paused. - parameters: - - $ref: '#/components/parameters/SandboxId' - responses: - '202': - description: | - Pause operation accepted. - - Sandbox will transition to Pausing state and eventually Paused. - Poll GET /sandboxes/{sandboxId} to track progress. - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}/resume: - post: - tags: [Sandboxes] - summary: Resume a paused sandbox - description: Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition through Resuming and eventually Running. - parameters: - - $ref: '#/components/parameters/SandboxId' - responses: - '202': - description: | - Resume operation accepted. - - Sandbox will transition from Paused → Resuming → Running. - Poll GET /sandboxes/{sandboxId} to track progress. - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}/renew-expiration: - post: - tags: [Sandboxes] - summary: Renew sandbox expiration - description: Renew the absolute expiration time of a sandbox. - parameters: - - $ref: '#/components/parameters/SandboxId' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RenewSandboxExpirationRequest' - responses: - '200': - description: | - Sandbox expiration updated successfully. - - Returns only the updated expiresAt field. - content: - application/json: - schema: - $ref: '#/components/schemas/RenewSandboxExpirationResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - /sandboxes/{sandboxId}/endpoints/{port}: - get: - tags: [Sandboxes] - summary: Get sandbox access endpoint - description: | - Get the public access endpoint URL for accessing a service running on a specific port - within the sandbox. The service must be listening on the specified port inside - the sandbox for the endpoint to be available. - parameters: - - $ref: '#/components/parameters/SandboxId' - - name: port - in: path - required: true - description: Port number where the service is listening inside the sandbox - schema: - type: integer - minimum: 1 - maximum: 65535 - - name: use_server_proxy - in: query - description: Whether to return a server-proxied URL - schema: - type: boolean - default: false - - name: expires - in: query - required: false - description: | - Optional. When set, the server **issues a signed** access route (OSEP-0011). The value - is **Linux / Unix epoch seconds** — a decimal `uint64` count of **whole seconds** since - the Unix epoch (`1970-01-01 00:00:00` UTC, same as POSIX / `time(2)`), not - milliseconds. Normalized to `expires_b36` for the four-segment route token. Omit to - get the unsigned/legacy response shape. - schema: - type: string - pattern: '^(0|[1-9][0-9]*)$' - minLength: 1 - maxLength: 20 - responses: - '200': - description: | - Endpoint retrieved successfully. - - Returns the public URL for accessing the service on the specified port. - content: - application/json: - schema: - $ref: '#/components/schemas/Endpoint' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' -components: - securitySchemes: - apiKeyAuth: - type: apiKey - in: header - name: OPEN-SANDBOX-API-KEY - description: | - API Key for authentication. Can be provided via: - 1. HTTP Header: OPEN-SANDBOX-API-KEY: your-api-key - 2. Environment variable: OPEN_SANDBOX_API_KEY (for SDK clients) - parameters: - SandboxId: - name: sandboxId - in: path - required: true - description: Unique sandbox identifier - schema: - type: string - SnapshotId: - name: snapshotId - in: path - required: true - description: Unique snapshot identifier - schema: - type: string - headers: - XRequestId: - description: Unique request identifier for tracing - schema: - type: string - format: uuid - Location: - description: URI of the newly created or related resource - schema: - type: string - format: uri - RetryAfter: - description: Suggested delay in seconds before retrying - schema: - type: integer - minimum: 1 - responses: - Error: - description: Error response envelope - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - BadRequest: - description: The request was invalid or malformed - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - Unauthorized: - description: Authentication credentials are missing or invalid - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - Forbidden: - description: The authenticated user lacks permission for this operation - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - NotFound: - description: The requested resource does not exist - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - Conflict: - description: The operation conflicts with the current state - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - InternalServerError: - description: An unexpected server error occurred - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - headers: - X-Request-ID: - $ref: '#/components/headers/XRequestId' - schemas: - ListSandboxesResponse: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/Sandbox' - pagination: - $ref: '#/components/schemas/PaginationInfo' - required: [items, pagination] - ListSnapshotsResponse: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/Snapshot' - pagination: - $ref: '#/components/schemas/PaginationInfo' - required: [items, pagination] - PaginationInfo: - type: object - description: Pagination metadata for list responses - properties: - page: - type: integer - minimum: 1 - description: Current page number - pageSize: - type: integer - minimum: 1 - description: Number of items per page - totalItems: - type: integer - minimum: 0 - description: Total number of items matching the filter - totalPages: - type: integer - minimum: 0 - description: Total number of pages - hasNextPage: - type: boolean - description: Whether there are more pages after the current one - required: [page, pageSize, totalItems, totalPages, hasNextPage] - CreateSandboxResponse: - type: object - description: Response from creating a new sandbox. Contains essential information without startup source details and updatedAt. - properties: - id: - type: string - description: Unique sandbox identifier - - status: - $ref: '#/components/schemas/SandboxStatus' - description: Current lifecycle status and detailed state information - - metadata: - type: object - additionalProperties: - type: string - description: Custom metadata from creation request - - platform: - $ref: '#/components/schemas/PlatformSpec' - description: | - Platform constraint echoed from request or workload template. - Null when no scheduling constraint is provided. - - expiresAt: - type: string - format: date-time - description: Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. - - createdAt: - type: string - format: date-time - description: Sandbox creation timestamp - - entrypoint: - type: array - items: - type: string - description: | - Entry process specification for the sandbox. For image-created sandboxes, - this is copied from the creation request. For snapshot-created sandboxes, - this is restored from the snapshot. - - required: - - id - - status - - createdAt - - entrypoint - - CreateSnapshotRequest: - type: object - description: Optional settings for creating a sandbox snapshot. - properties: - name: - type: string - description: Optional human-readable snapshot name. - minLength: 1 - additionalProperties: false - - Snapshot: - type: object - description: Persistent point-in-time capture of a sandbox. - properties: - id: - type: string - description: Unique snapshot identifier - - sandboxId: - type: string - description: Source sandbox identifier used to create this snapshot - - name: - type: string - description: Optional human-readable snapshot name - - status: - $ref: '#/components/schemas/SnapshotStatus' - description: Current snapshot lifecycle status and detailed state information - - createdAt: - type: string - format: date-time - description: Snapshot creation timestamp - - required: - - id - - sandboxId - - status - - createdAt - additionalProperties: false - - SnapshotState: - type: string - description: | - Snapshot lifecycle state. - - Common state values: - - Creating: Snapshot creation has been accepted and runtime capture is in progress. - - Deleting: Snapshot deletion has been requested and cleanup is in progress. - - Ready: Snapshot is available for restoring sandboxes. - - Failed: Snapshot creation failed. - - Note: New state values may be added in future versions. - Clients should handle unknown state values gracefully. - - SnapshotStatus: - type: object - description: Detailed snapshot status information with lifecycle state and transition details. - properties: - state: - $ref: '#/components/schemas/SnapshotState' - description: Current lifecycle state of the snapshot - - reason: - type: string - description: | - Short machine-readable reason code for the current state. - Examples: "snapshot_accepted", "snapshot_ready", "snapshot_capture_failed" - - message: - type: string - description: Human-readable message describing the current state or failure reason - - lastTransitionAt: - type: string - format: date-time - description: Timestamp of the last state transition - - required: [state] - additionalProperties: false - - Sandbox: - type: object - description: Runtime execution environment provisioned from a container image or restored from a snapshot - properties: - id: - type: string - description: Unique sandbox identifier - - image: - $ref: '#/components/schemas/ImageSpec' - description: | - Container image specification used to provision this sandbox. - Present when the sandbox was created directly from a container image. - Not returned in createSandbox response. - - snapshotId: - type: string - description: | - Snapshot identifier used to restore this sandbox. - Present when the sandbox was restored from a snapshot. - Not returned in createSandbox response. - - platform: - $ref: '#/components/schemas/PlatformSpec' - description: | - Platform constraint echoed from request or workload template. - Null when no scheduling constraint is provided. - - status: - $ref: '#/components/schemas/SandboxStatus' - description: Current lifecycle status and detailed state information - - metadata: - type: object - additionalProperties: - type: string - description: Custom metadata from creation request - - entrypoint: - type: array - items: - type: string - description: | - The command to execute as the sandbox's entry process. - Always present in responses. For image-created sandboxes, this is copied - from the creation request. For snapshot-created sandboxes, this is restored - from the snapshot. - - expiresAt: - type: string - format: date-time - description: Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. - - createdAt: - type: string - format: date-time - description: Sandbox creation timestamp - - required: - - id - - status - - createdAt - - entrypoint - SandboxState: - type: string - description: | - High-level lifecycle state of the sandbox. - - Common state values: - - Pending: Sandbox is being provisioned - - Running: Sandbox is running and ready to accept requests - - Pausing: Sandbox is in the process of pausing - - Paused: Sandbox has been paused while retaining its state - - Resuming: Sandbox is being restored after a pause - - Stopping: Sandbox is being terminated - - Terminated: Sandbox has been successfully terminated - - Failed: Sandbox encountered a critical error - - State transitions: - - Pending → Running (after creation completes) - - Running → Pausing (when pause is requested) - - Pausing → Paused (pause operation completes) - - Paused → Resuming (when resume is requested) - - Resuming → Running (when resume operation completes) - - Running/Paused → Stopping (when kill is requested or TTL expires) - - Stopping → Terminated (kill/timeout operation completes) - - Pending/Running/Paused/Resuming → Failed (on error) - - Note: New state values may be added in future versions. - Clients should handle unknown state values gracefully. - SandboxStatus: - type: object - description: Detailed status information with lifecycle state and transition details - properties: - state: - $ref: '#/components/schemas/SandboxState' - description: Current lifecycle state of the sandbox - - reason: - type: string - description: | - Short machine-readable reason code for the current state. - Examples: "user_delete", "ttl_expiry", "provision_timeout", "runtime_error" - - message: - type: string - description: Human-readable message describing the current state or reason for state transition - - lastTransitionAt: - type: string - format: date-time - description: Timestamp of the last state transition - - required: [state] - ImageSpec: - type: object - required: [uri] - description: | - Container image specification for sandbox provisioning. - - Supports public registry images and private registry images with authentication. - properties: - uri: - type: string - description: | - Container image URI in standard format. - - Examples: - - "python:3.11" (Docker Hub) - - "ubuntu:22.04" - - "gcr.io/my-project/model-server:v1.0" - - "private-registry.company.com:5000/app:latest" - - auth: - type: object - description: Registry authentication credentials (required for private registries) - properties: - username: - type: string - description: Registry username or service account - password: - type: string - description: Registry password or authentication token - additionalProperties: false - - additionalProperties: false - PlatformSpec: - type: object - required: [os, arch] - description: | - Runtime platform constraint used for scheduling/provisioning. - - This field is independent from `image` and expresses the expected target - OS and CPU architecture for sandbox execution. - - Behavioral notes: - - If omitted, the runtime applies its own default platform selection behavior. - For Docker, requests are created without an explicit platform override. - For Kubernetes, no `kubernetes.io/os` or `kubernetes.io/arch` constraint - is injected unless provided by request or workload template. - - If provided and cannot be satisfied by runtime/template/pool constraints, - request must fail explicitly. - properties: - os: - type: string - enum: [linux, windows] - description: Target operating system (for example `linux` or `windows`). - example: linux - arch: - type: string - enum: [amd64, arm64] - description: Target CPU architecture (for example `amd64` or `arm64`). - example: arm64 - additionalProperties: false - PatchSandboxMetadataRequest: - type: object - description: | - JSON Merge Patch (RFC 7396) request body for updating sandbox metadata. - - The request body is the metadata object itself: - - Present keys with non-null values add or replace - - Keys with `null` values are deleted - - Absent keys are left unchanged - - Keys with the `opensandbox.io/` prefix are reserved and rejected. - additionalProperties: - type: - - string - - 'null' - example: - project: "new-project" - team: null - environment: "production" - - CreateSandboxRequest: - type: object - description: | - Request to create a new sandbox from either a container image, a snapshot, - or a pre-configured pool (via `extensions.poolRef`). - - **Standard mode**: Exactly one of `image` or `snapshotId` must be provided, - and `resourceLimits` is required. - - When `image` is provided, `entrypoint` is required. When `snapshotId` is - provided, `entrypoint` is optional. If omitted, the server defaults the - sandbox entrypoint to `["tail", "-f", "/dev/null"]`. - - **Pool mode**: When `extensions.poolRef` is set, the sandbox is created from - a pre-configured pool. In this case `image`, `entrypoint`, and - `resourceLimits` are all optional (defined by the Pool CRD template). - `snapshotId` must not be provided together with `poolRef`. - - **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header. - properties: - image: - $ref: '#/components/schemas/ImageSpec' - description: | - Container image specification for the sandbox. - Mutually exclusive with `snapshotId`. - - snapshotId: - type: string - description: | - Snapshot identifier to restore from. - Mutually exclusive with `image`. - - platform: - $ref: '#/components/schemas/PlatformSpec' - description: | - Optional platform constraint for sandbox scheduling/runtime selection. - - If omitted, runtime default behavior applies (runtime-specific and not - a fixed architecture guarantee). If specified, the runtime must satisfy - this constraint or fail explicitly. - This field is only meaningful when scheduling constraints are set. - - timeout: - oneOf: - - type: integer - minimum: 60 - - type: 'null' - description: | - Sandbox timeout in seconds. The sandbox will automatically terminate after this duration. - The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`). - Omit this field or set it to null to disable automatic expiration and require explicit cleanup. - Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject - omitted or null timeout when the underlying workload provider does not support non-expiring sandboxes. - - resourceLimits: - $ref: '#/components/schemas/ResourceLimits' - description: | - Runtime resource constraints for the sandbox instance. - Required when `extensions.poolRef` is not set. - Optional when using pool mode (resource limits are defined by the Pool CRD template). - SDK clients should provide sensible defaults (e.g., cpu: "500m", memory: "512Mi"). - - env: - type: object - additionalProperties: - type: string - description: Environment variables to inject into the sandbox runtime. - example: - API_KEY: "secret-key" - DEBUG: "true" - LOG_LEVEL: "info" - - metadata: - type: object - additionalProperties: - type: string - description: | - Custom key-value metadata for management, filtering, and tagging. - Use "name" key for a human-readable identifier. - example: - name: "Data Processing Sandbox" - project: "data-processing" - team: "ml" - environment: "staging" - - entrypoint: - type: array - items: - type: string - minItems: 1 - description: | - The command to execute as the sandbox's entry process. - - Required when `image` is provided. - - Optional when `snapshotId` is provided. If omitted for snapshot - restore, the server defaults to `["tail", "-f", "/dev/null"]`. - - Explicitly specifies the user's expected main process, allowing the sandbox management - service to reliably inject control processes before executing this command. - - Format: [executable, arg1, arg2, ...] - - Examples: - - ["python", "/app/main.py"] - - ["/bin/bash"] - - ["java", "-jar", "/app/app.jar"] - - ["node", "server.js"] - example: - - "python" - - "/app/main.py" - - networkPolicy: - $ref: '#/components/schemas/NetworkPolicy' - description: | - Optional outbound network policy for the sandbox. - Shape matches the sidecar `/policy` endpoint. If omitted or empty, - the sidecar starts in allow-all mode until updated. - - secureAccess: - type: boolean - default: false - description: | - Opts the sandbox into secured access for endpoint access. - This is currently supported only for Kubernetes sandboxes exposed - through ingress gateway mode. When enabled, the server provisions - access credentials and returns the required request headers with - endpoint responses. Clients must include those endpoint headers when - calling the sandbox. When omitted or false, endpoints remain - accessible without the additional access token for backward - compatibility. - - volumes: - type: array - description: | - Storage mounts for the sandbox. Each volume entry specifies a named backend-specific - storage source and common mount settings. Exactly one backend type must be specified - per volume entry. - items: - $ref: '#/components/schemas/Volume' - - extensions: - type: object - additionalProperties: - type: string - description: | - Opaque container for provider-specific or transient parameters not supported by the core API. - - **Note**: This field is reserved for internal features, experimental flags, or temporary behaviors. Standard parameters should be proposed as core API fields. - - **Best Practices**: - - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions. - - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently. - - **Well-known keys**: - - `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24 hours inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit to disable. Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint via `validate_extensions` in server `src/extensions/validation.py`). - ResourceLimits: - type: object - description: | - Runtime resource constraints as key-value pairs. Similar to Kubernetes resource specifications, - allows flexible definition of resource limits. Common resource types include: - - `cpu`: CPU allocation in millicores (e.g., "250m" for 0.25 CPU cores) - - `memory`: Memory allocation in bytes or human-readable format (e.g., "512Mi", "1Gi") - - `gpu`: Number of GPU devices (e.g., "1") - - New resource types can be added without API changes. - additionalProperties: - type: string - example: - cpu: "500m" - memory: "512Mi" - gpu: "1" - RenewSandboxExpirationRequest: - type: object - required: [expiresAt] - properties: - expiresAt: - type: string - format: date-time - description: | - New absolute expiration time in UTC (RFC 3339 format). - Must be in the future and after the current expiresAt time. - - Example: "2025-11-16T14:30:45Z" - additionalProperties: false - RenewSandboxExpirationResponse: - type: object - required: [expiresAt] - properties: - expiresAt: - type: string - format: date-time - description: | - The new absolute expiration time in UTC (RFC 3339 format). - - Example: "2025-11-16T14:30:45Z" - additionalProperties: false - ErrorResponse: - type: object - description: | - Standard error response for all non-2xx HTTP responses. - HTTP status code indicates the error category; code and message provide details. - properties: - code: - type: string - description: | - Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR). - Use this for programmatic error handling. - message: - type: string - description: Human-readable error message describing what went wrong and how to fix it. - required: [code, message] - additionalProperties: false - Endpoint: - type: object - description: | - Endpoint for accessing a service running in the sandbox. - The service must be listening on the specified port inside the sandbox for the endpoint to be available. - properties: - endpoint: - type: string - description: | - Public URL to access the service from outside the sandbox. - Format: {endpoint-host}/sandboxes/{sandboxId}/port/{port} - Example: endpoint.opensandbox.io/sandboxes/abc123/port/8080 - headers: - type: object - additionalProperties: - type: string - description: | - Requests targeting the sandbox must include the corresponding header(s). - required: - - endpoint - additionalProperties: false - - NetworkPolicy: - type: object - description: | - Egress network policy matching the sidecar `/policy` request body. - If `defaultAction` is omitted, the sidecar defaults to "deny"; passing an empty - object or null results in allow-all behavior at startup. - properties: - defaultAction: - type: string - enum: [allow, deny] - description: Default action when no egress rule matches. Defaults to "deny". - egress: - type: array - description: List of egress rules evaluated in order. - items: - $ref: '#/components/schemas/NetworkRule' - additionalProperties: false - - NetworkRule: - type: object - properties: - action: - type: string - enum: [allow, deny] - description: Whether to allow or deny matching targets. - target: - type: string - description: | - FQDN or wildcard domain (e.g., "example.com", "*.example.com"). - IP/CIDR not yet supported in the egress MVP. - required: [action, target] - additionalProperties: false - - Volume: - type: object - description: | - Storage mount definition for a sandbox. Each volume entry contains: - - A unique name identifier - - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields - - Common mount settings (mountPath, readOnly, subPath) - required: [name, mountPath] - properties: - name: - type: string - description: | - Unique identifier for the volume within the sandbox. - Must be a valid DNS label (lowercase alphanumeric, hyphens allowed, max 63 chars). - pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" - maxLength: 63 - host: - $ref: '#/components/schemas/Host' - pvc: - $ref: '#/components/schemas/PVC' - ossfs: - $ref: '#/components/schemas/OSSFS' - mountPath: - type: string - description: | - Absolute path inside the container where the volume is mounted. - Must start with '/'. - pattern: "^/.*" - readOnly: - type: boolean - description: | - If true, the volume is mounted as read-only. Defaults to false (read-write). - default: false - subPath: - type: string - description: | - Optional subdirectory under the backend path to mount. - For `ossfs` backend, this field is used as the bucket prefix. - Must be a relative path without '..' components. - additionalProperties: false - - Host: - type: object - description: | - Host path bind mount backend. Maps a directory on the host filesystem - into the container. Only available when the runtime supports host mounts. - - Security note: Host paths are restricted by server-side allowlist. - Users must specify paths under permitted prefixes. - required: [path] - properties: - path: - type: string - description: | - Absolute path on the host filesystem to mount. - Must start with '/' (Unix) or a drive letter such as 'C:\' or 'D:/' - (Windows), and be under an allowed prefix. - pattern: "^(/|[A-Za-z]:[\\\\/])" - additionalProperties: false - - PVC: - type: object - description: | - Platform-managed named volume backend. A runtime-neutral abstraction - for referencing a platform-managed named volume. If `createIfNotExists` - is true (the default) and the volume does not yet exist, it will be - created automatically using the provisioning hints below. - - - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. - - Docker: maps to a Docker named volume (created via `docker volume create`). - required: [claimName] - properties: - claimName: - type: string - description: | - Name of the volume on the target platform. - In Kubernetes this is the PVC name; in Docker this is the named - volume name. Must be a valid DNS label. - pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" - maxLength: 253 - createIfNotExists: - type: boolean - default: true - description: | - When true (the default), the volume is automatically created if - it does not exist. When false, referencing a non-existent volume - fails with an error. - deleteOnSandboxTermination: - type: boolean - default: false - description: | - When true, the volume is automatically removed when the sandbox - is deleted. Only applies to volumes that were auto-created by the - server (Docker only). Pre-existing volumes are never removed. - Has no effect on Kubernetes PVCs, whose lifecycle is managed by - the StorageClass reclaim policy. - storageClass: - type: string - nullable: true - description: | - Kubernetes StorageClass name for auto-created PVCs. Null means - use the cluster default. Ignored for Docker volumes. - storage: - type: string - nullable: true - description: | - Storage capacity request for auto-created PVCs (e.g. "1Gi", - "10Gi"). Defaults to the server-configured `volume_default_size` - when omitted. Ignored for Docker volumes. - pattern: "^\\d+(\\.\\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)?$" - accessModes: - type: array - nullable: true - items: - type: string - description: | - Access modes for auto-created PVCs (e.g. ["ReadWriteOnce"]). - Defaults to ["ReadWriteOnce"] when omitted. Ignored for Docker - volumes. - additionalProperties: false - - OSSFS: - type: object - description: | - Alibaba Cloud OSS mount backend via ossfs. - - The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` - and bind-mounts the resolved path into the sandbox container. - Prefix selection is expressed via `Volume.subPath`. - In Docker runtime, OSSFS backend requires OpenSandbox Server to run on a Linux host with FUSE support. - required: [bucket, endpoint, accessKeyId, accessKeySecret] - properties: - bucket: - type: string - description: OSS bucket name. - minLength: 3 - maxLength: 63 - endpoint: - type: string - description: OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). - minLength: 1 - version: - type: string - description: ossfs major version used by runtime mount integration. - enum: ["1.0", "2.0"] - default: "2.0" - options: - type: array - description: | - Additional ossfs mount options. - Runtime encodes options by `version`: - - `1.0`: mounts with `ossfs ... -o