diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3998a69c36a..05712d24189 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ "features": { "ghcr.io/devcontainers/features/node:1": { "nodeGypDependencies": true, - "version": "lts" + "version": "24.20.0" }, "ghcr.io/devcontainers-extra/features/npm-package:1": { "package": "typescript", @@ -46,4 +46,4 @@ // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. -} \ No newline at end of file +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 070880ffc24..fd64ccdec9a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -28,6 +28,7 @@ # E2E /e2e/ @lyzno1 +/.github/workflows/web-e2e.yml @lyzno1 # Backend (default owner, more specific rules below will override) /api/ @QuantumGhost diff --git a/.github/actions/setup-web/action.yml b/.github/actions/setup-web/action.yml index 68c330d0e2c..dbc20df50af 100644 --- a/.github/actions/setup-web/action.yml +++ b/.github/actions/setup-web/action.yml @@ -11,6 +11,6 @@ runs: - name: Setup Vite+ uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0 with: - node-version-file: .nvmrc + node-version-file: package.json cache: true run-install: true diff --git a/.github/labeler.yml b/.github/labeler.yml index c2ea8873781..911aa34e37a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,7 +6,6 @@ web: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - - '.nvmrc' e2e: - changed-files: diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 7ad86aa41b8..6023aa7fae2 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -82,7 +82,6 @@ jobs: - 'pnpm-workspace.yaml' - 'lint.config.ts' - '.npmrc' - - '.nvmrc' - '.github/workflows/cli-tests.yml' - '.github/actions/setup-web/**' web: @@ -91,7 +90,6 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - - '.nvmrc' - '.github/workflows/main-ci.yml' - '.github/workflows/web-tests.yml' - '.github/actions/setup-web/**' @@ -105,7 +103,6 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - - '.nvmrc' - 'docker/docker-compose.middleware.yaml' - 'docker/envs/middleware.env.example' - '.github/workflows/web-e2e.yml' diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index de1fce7977c..8f410b887be 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -39,7 +39,6 @@ jobs: - 'e2e/tsx-register.js' - 'package.json' - 'pnpm-lock.yaml' - - '.nvmrc' - '.github/workflows/post-merge.yml' - '.github/workflows/web-e2e.yml' - '.github/actions/setup-web/**' diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index e2dc5504205..ca9d93d136e 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -114,7 +114,6 @@ jobs: pnpm-workspace.yaml knip.config.ts scripts/check-web-production-unused-after-knip-fix.mjs - .nvmrc .github/workflows/style.yml .github/actions/setup-web/** @@ -170,7 +169,6 @@ jobs: package.json pnpm-lock.yaml pnpm-workspace.yaml - .nvmrc vite.config.ts lint.config.ts eslint.config.mjs diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index 2d0133131eb..e6ab2459edf 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -31,7 +31,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version-file: package.json cache: '' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 20446914e6e..e24e98f356f 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -162,7 +162,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210 + uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index e6bc08d44af..8700df8c1cc 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -12,13 +12,196 @@ permissions: contents: read jobs: - test: + core-build: + name: Prepare Core E2E Web Build + if: ${{ !inputs.run-external-runtime }} + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup web dependencies + uses: ./.github/actions/setup-web + + - name: Run E2E support unit tests + working-directory: ./e2e + run: vp run test:unit + + - name: Build production Web app + working-directory: ./e2e + env: + E2E_FORCE_WEB_BUILD: '1' + run: vp run e2e:web:build + + - name: Package Web build + run: >- + tar -cf e2e-web-build.tar -C web + .next/BUILD_ID + .next/e2e-web-build.sha256 + .next/standalone + .next/static + + - name: Upload Web build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: core-e2e-web-build + path: e2e-web-build.tar + if-no-files-found: error + overwrite: true + retention-days: 7 + + chromium-full: + name: Chromium Full (${{ matrix.shard }}/3) + if: ${{ !inputs.run-external-runtime }} + needs: core-build + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + env: + E2E_ADMIN_EMAIL: e2e-admin@example.com + E2E_ADMIN_NAME: E2E Admin + E2E_ADMIN_PASSWORD: E2eAdmin12345 + E2E_INIT_PASSWORD: E2eInit12345 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup web dependencies + uses: ./.github/actions/setup-web + + - name: Setup UV and Python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + python-version: '3.12' + cache-dependency-glob: api/uv.lock + + - name: Install API dependencies + run: uv sync --project api --dev + + - name: Download Web build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: core-e2e-web-build + + - name: Extract Web build + run: tar -xf e2e-web-build.tar -C web + + - name: Install Chromium + timeout-minutes: 15 + working-directory: ./e2e + run: vp run e2e:install:ci:chromium + + - name: Run Chromium full shard + working-directory: ./e2e + run: vp run e2e:full -- --shard ${{ matrix.shard }}/3 + + - name: Upload Cucumber report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cucumber-report-chromium-${{ matrix.shard }} + path: e2e/cucumber-report + if-no-files-found: ignore + overwrite: true + retention-days: 7 + + - name: Upload E2E logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-logs-chromium-${{ matrix.shard }} + path: e2e/.logs/*.log + if-no-files-found: ignore + include-hidden-files: true + overwrite: true + retention-days: 7 + + webkit-smoke: + name: WebKit Browser Smoke + if: ${{ !inputs.run-external-runtime }} + needs: core-build + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + E2E_ADMIN_EMAIL: e2e-admin@example.com + E2E_ADMIN_NAME: E2E Admin + E2E_ADMIN_PASSWORD: E2eAdmin12345 + E2E_BROWSER: webkit + E2E_INIT_PASSWORD: E2eInit12345 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup web dependencies + uses: ./.github/actions/setup-web + + - name: Setup UV and Python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + python-version: '3.12' + cache-dependency-glob: api/uv.lock + + - name: Install API dependencies + run: uv sync --project api --dev + + - name: Download Web build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: core-e2e-web-build + + - name: Extract Web build + run: tar -xf e2e-web-build.tar -C web + + - name: Install WebKit + timeout-minutes: 15 + working-directory: ./e2e + run: vp run e2e:install:ci:webkit + + - name: Run WebKit browser smoke + working-directory: ./e2e + run: vp run e2e:full -- --tags '@browser-smoke' + + - name: Upload Cucumber report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cucumber-report-webkit + path: e2e/cucumber-report + if-no-files-found: ignore + overwrite: true + retention-days: 7 + + - name: Upload E2E logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-logs-webkit + path: e2e/.logs/*.log + if-no-files-found: ignore + include-hidden-files: true + overwrite: true + retention-days: 7 + + external-runtime: name: Web Full-Stack E2E + if: ${{ inputs.run-external-runtime }} runs-on: ubuntu-24.04 timeout-minutes: 120 - defaults: - run: - shell: bash steps: - name: Checkout code @@ -41,82 +224,12 @@ jobs: - name: Install API dependencies run: uv sync --project api --dev - - name: Run E2E support unit tests - if: ${{ !inputs.run-external-runtime }} - working-directory: ./e2e - run: vp run test:unit - - - name: Install Playwright browsers for core E2E - if: ${{ !inputs.run-external-runtime }} - timeout-minutes: 15 - working-directory: ./e2e - run: vp run e2e:install:ci - - - name: Install Chromium for external runtime E2E - if: ${{ inputs.run-external-runtime }} + - name: Install Chromium timeout-minutes: 15 working-directory: ./e2e run: vp run e2e:install:ci:chromium - - name: Run isolated source-api and built-web Cucumber E2E tests - if: ${{ !inputs.run-external-runtime }} - working-directory: ./e2e - env: - E2E_ADMIN_EMAIL: e2e-admin@example.com - E2E_ADMIN_NAME: E2E Admin - E2E_ADMIN_PASSWORD: E2eAdmin12345 - E2E_FORCE_WEB_BUILD: '1' - E2E_INIT_PASSWORD: E2eInit12345 - run: vp run e2e:full - - - name: Preserve Chromium E2E report and logs - if: ${{ !cancelled() && !inputs.run-external-runtime }} - run: | - if [[ -d e2e/cucumber-report ]]; then - mv e2e/cucumber-report e2e/cucumber-report-non-external - fi - if [[ -d e2e/.logs ]]; then - mv e2e/.logs e2e/.logs-non-external - fi - - - name: Run WebKit keyboard and browser smoke tests - if: ${{ !inputs.run-external-runtime }} - working-directory: ./e2e - env: - E2E_ADMIN_EMAIL: e2e-admin@example.com - E2E_ADMIN_NAME: E2E Admin - E2E_ADMIN_PASSWORD: E2eAdmin12345 - E2E_BROWSER: webkit - E2E_INIT_PASSWORD: E2eInit12345 - run: | - teardown_webkit_smoke() { - local run_status=$? - trap - EXIT - if ! vp run e2e:middleware:down; then - echo "::error title=E2E teardown failed::WebKit smoke middleware did not shut down cleanly." - if [[ "$run_status" -eq 0 ]]; then - run_status=1 - fi - fi - exit "$run_status" - } - - trap teardown_webkit_smoke EXIT - vp run e2e:middleware:up - vp run e2e -- --tags '@browser-smoke' - - - name: Preserve WebKit E2E report and logs - if: ${{ !cancelled() && !inputs.run-external-runtime }} - run: | - if [[ -d e2e/cucumber-report ]]; then - mv e2e/cucumber-report e2e/cucumber-report-webkit - fi - if [[ -d e2e/.logs ]]; then - mv e2e/.logs e2e/.logs-webkit - fi - - name: Run prepared and external runtime E2E tests - if: ${{ inputs.run-external-runtime }} working-directory: ./e2e env: E2E_ADMIN_EMAIL: e2e-admin@example.com @@ -149,10 +262,8 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cucumber-report - path: | - e2e/cucumber-report - e2e/cucumber-report-non-external - e2e/cucumber-report-webkit + path: e2e/cucumber-report + overwrite: true retention-days: 7 - name: Upload E2E logs @@ -160,18 +271,17 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-logs - path: | - e2e/.logs/*.log - e2e/.logs-non-external/*.log - e2e/.logs-webkit/*.log + path: e2e/.logs/*.log include-hidden-files: true + overwrite: true retention-days: 7 - name: Upload E2E seed report - if: ${{ !cancelled() && inputs.run-external-runtime }} + if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-seed-report path: e2e/seed-report if-no-files-found: ignore + overwrite: true retention-days: 7 diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2bd5a0a98a3..00000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/AGENTS.md b/AGENTS.md index 6f5673b316a..d7b7320aa2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,9 @@ Dify is an open-source platform for building LLM applications, agentic workflows - Run backend commands through `uv run --project api `. - Backend integration tests are CI-only and are not expected to run locally. - Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files. + +## Frontend Workflow + +- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure]. + +[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md diff --git a/api/.importlinter b/api/.importlinter index 7cf69c0c515..2df3d8d8d68 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -65,7 +65,7 @@ ignore_imports = core.app.workflow.layers.persistence -> services.workflow.inspector_events core.datasource.datasource_manager -> services.datasource_provider_service core.helper.credential_utils -> services.enterprise.plugin_manager_service - core.helper.credential_utils -> services.feature_service + core.helper.credential_utils -> services.system_feature_service core.indexing_runner -> services.vector_space_admission_service core.mcp.auth_client -> services core.provider_manager -> services.credential_permission_service @@ -99,9 +99,9 @@ ignore_imports = core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task libs.device_flow_security -> controllers.openapi._models libs.device_flow_security -> services.entities.feature_entities - libs.device_flow_security -> services.feature_service + libs.device_flow_security -> services.system_feature_service libs.email_i18n -> services.entities.feature_entities - libs.email_i18n -> services.feature_service + libs.email_i18n -> services.system_feature_service libs.external_api -> core libs.external_api -> core.errors.error libs.external_api -> extensions.ext_logging @@ -117,7 +117,6 @@ ignore_imports = libs.oauth_bearer -> models libs.rsa -> extensions.ext_storage libs.workspace_permission -> services.enterprise.enterprise_service - libs.workspace_permission -> services.feature_service services.account_service -> controllers services.account_service -> controllers.console.error services.app_generate_service -> controllers.console.app.workflow @@ -387,6 +386,37 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:inner-mail-service-boundary] +name = Inner mail application service is framework and implementation neutral +type = forbidden +source_modules = + services.inner_mail_service +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + sqlalchemy + tasks + werkzeug + +[importlinter:contract:web-passport-service-boundary] +name = Web passport application service is framework and persistence neutral +type = forbidden +source_modules = + services.web_passport_service +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + sqlalchemy + werkzeug + [importlinter:contract:account-activation-service-boundary] name = Account activation application service is framework and persistence neutral type = forbidden diff --git a/api/Dockerfile b/api/Dockerfile index 86eef5d329f..7c9b5d1a158 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -53,15 +53,14 @@ WORKDIR /app/api # Create non-root user ARG dify_uid=1001 -ARG NODE_MAJOR=22 -ARG NODE_PACKAGE_VERSION=22.21.0-1nodesource1 +ARG NODE_PACKAGE_VERSION=24.20.0-1nodesource1 ARG NODESOURCE_KEY_FPR=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 RUN groupadd -r -g ${dify_uid} dify && \ useradd -r -u ${dify_uid} -g ${dify_uid} -s /bin/bash dify && \ chown -R dify:dify /app -RUN \ - apt-get update \ +RUN NODE_MAJOR="${NODE_PACKAGE_VERSION%%.*}" \ + && apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 8ae2db85ff0..e5ed3ed31e9 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.common.session import with_session from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model +from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -527,8 +527,7 @@ class AgentComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_EDIT) @with_current_user_id @with_current_tenant_id @with_session diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 730dd7b1a57..6c2798a2017 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -7,7 +7,6 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator from sqlalchemy import func, or_, select from sqlalchemy.orm import Session -from configs import dify_config from controllers.common.schema import ( query_params_from_model, query_params_from_request, @@ -33,6 +32,7 @@ from controllers.console.app.app import ( from controllers.console.app.app import ( UpdateAppPayload as GenericUpdateAppPayload, ) +from controllers.console.app.wraps import agent_manage_required_for_agent_app from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -79,11 +79,9 @@ from services.agent.observability_service import ( ) from services.agent.roster_service import AgentRosterService from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams -from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import ComposerSavePayload, RosterListQuery -from services.feature_service import FeatureService -from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task +from services.system_feature_service import SystemFeatureService AgentPublicationStatus = Literal["published", "drafts"] @@ -390,7 +388,7 @@ def _serialize_agent_app_detail( """ app_model = AppService().get_app(app_model, session=session) - if FeatureService.get_system_features().webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] @@ -687,15 +685,6 @@ class AgentAppListApi(Resource): ) app = AppService().create_app(current_tenant_id, params, current_user, session=session) - if dify_config.RBAC_ENABLED: - enterprise_rbac_service.RBACService.AppAccess.replace_whitelist( - current_tenant_id, - current_user.id, - str(app.id), - enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True), - ) - initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id) - return _serialize_agent_app_detail(session, app, current_user=current_user), 201 @@ -995,8 +984,7 @@ class AgentApiStatusApi(Resource): @login_required @is_admin_or_owner_required @account_initialization_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_current_tenant_id @with_session @model_validate(AgentApiStatusPayload) @@ -1014,10 +1002,9 @@ class AgentApiKeyListApi(BaseApiKeyListResource): token_prefix = "app-" @console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__]) - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_current_tenant_id @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @with_session(write=False) def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]: app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id) @@ -1027,8 +1014,7 @@ class AgentApiKeyListApi(BaseApiKeyListResource): @console_ns.response(400, "Maximum keys exceeded") @with_current_tenant_id @edit_permission_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_session def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]: app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id) @@ -1047,8 +1033,7 @@ class AgentApiKeyApi(BaseApiKeyResource): @console_ns.response(204, "Agent service API key deleted") @with_current_user @with_current_tenant_id - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_session def delete( self, diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index e58d25d0059..b306d570e9e 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -23,7 +23,7 @@ from controllers.common.schema import ( ) from controllers.console import console_ns from controllers.console.app.error import AppNotFoundError -from controllers.console.app.wraps import get_app_model +from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -153,7 +153,7 @@ class AgentAppSandboxInfoResource(Resource): @setup_required @login_required @account_initialization_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id @with_current_user def get(self, current_user: Account, tenant_id: str, agent_id: UUID): @@ -183,7 +183,7 @@ class AgentAppSandboxListResource(Resource): @setup_required @login_required @account_initialization_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id @with_current_user def get(self, current_user: Account, tenant_id: str, agent_id: UUID): @@ -214,7 +214,7 @@ class AgentAppSandboxReadResource(Resource): @setup_required @login_required @account_initialization_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id @with_current_user def get(self, current_user: Account, tenant_id: str, agent_id: UUID): @@ -245,7 +245,7 @@ class AgentAppSandboxDownloadResource(Resource): @setup_required @login_required @account_initialization_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id @with_current_user @model_validate(AgentSandboxDownloadPayload) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 358fd60c0fe..f14fe1eca95 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -77,7 +77,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( WeightVectorSetting, ) from services.errors.account import NoPermissionError -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] @@ -516,7 +516,7 @@ class AppImportResponse(ResponseModel): def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None: - if FeatureService.get_system_features().webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): app_ids = [str(app.id) for app in apps] res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids) if len(res) != len(app_ids): @@ -877,7 +877,7 @@ class AppApi(Resource): app_model = app_service.get_app(app_model, session=session) - if FeatureService.get_system_features().webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) app_model.access_mode = app_setting.access_mode @@ -1002,7 +1002,7 @@ class AppCopyApi(Resource): session.commit() # Inherit web app permission from original app - if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: + if result.app_id and SystemFeatureService.is_webapp_auth_enabled(): try: # Get the original app's access mode original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_model.id) @@ -1173,8 +1173,7 @@ class AppSiteStatus(Resource): @login_required @account_initialization_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) - @agent_manage_required_for_agent_app + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_session @get_app_model(mode=None) @model_validate(AppSiteStatusPayload) diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index dc60a3431a6..37a8cf21a14 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -31,7 +31,7 @@ from services.app_dsl_service import ( from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus from services.errors.account import NoPermissionError -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService from .. import console_ns from .permission_keys import get_app_permission_keys @@ -127,7 +127,7 @@ class AppImportApi(Resource): result.app_id, ) - if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: + if result.app_id and SystemFeatureService.is_webapp_auth_enabled(): # update web app setting as private EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") # Return appropriate status code based on result diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 5f3982a65dc..53509d00f26 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -24,7 +24,7 @@ from controllers.console.app.error import ( ProviderNotInitializeError, ProviderQuotaExceededError, ) -from controllers.console.app.wraps import get_app_model, with_session +from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -263,7 +263,7 @@ class AgentChatMessageApi(Resource): @login_required @account_initialization_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN) @with_current_user @with_current_tenant_id @with_session @@ -292,7 +292,7 @@ class AgentBuildChatFinalizeApi(Resource): @login_required @account_initialization_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN) @with_current_user @with_current_tenant_id @with_session diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 8f6c2f54464..1c5a74e5172 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -21,7 +21,7 @@ from controllers.console.app.error import ( ProviderNotInitializeError, ProviderQuotaExceededError, ) -from controllers.console.app.wraps import get_app_model +from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError from controllers.console.wraps import ( RBACPermission, @@ -174,7 +174,7 @@ class AgentChatMessageListApi(Resource): @account_initialization_required @setup_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT) @with_current_user @with_current_tenant_id @with_session(write=False) diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index f7a20be978b..7d1d7efc616 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -8,7 +8,6 @@ from constants.languages import supported_language from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import AppNotFoundError -from controllers.console.app.wraps import agent_manage_required_for_agent_app from controllers.console.flask_admission import console_account_admission from controllers.console.wraps import ( RBACPermission, @@ -108,8 +107,8 @@ class AppSite(Resource): allowed_roles=_APP_SITE_EDIT_ROLES, rbac_resource_scope=RBACResourceScope.APP, rbac_permission=RBACPermission.APP_RELEASE_AND_VERSION, + agent_manage_fallback=True, ) - @agent_manage_required_for_agent_app @model_validate(AppSiteUpdatePayload) def post( self, @@ -139,8 +138,8 @@ class AppSiteAccessTokenReset(Resource): allowed_roles=_APP_SITE_TOKEN_RESET_ROLES, rbac_resource_scope=RBACResourceScope.APP, rbac_permission=RBACPermission.APP_RELEASE_AND_VERSION, + agent_manage_fallback=True, ) - @agent_manage_required_for_agent_app def post(self, request_context: RequestContext, app_id: UUID): try: site = application_services().app_sites.reset_access_token(request_context, str(app_id)) diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py index e844348de85..8e18c6af01e 100644 --- a/api/controllers/console/app/wraps.py +++ b/api/controllers/console/app/wraps.py @@ -12,20 +12,22 @@ from typing import cast, overload from sqlalchemy import select from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.session import with_session -from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access +from controllers.common.wraps import RBACPermission, RBACResourceScope, _extract_resource_id, enforce_rbac_access from controllers.console.app.error import AppNotFoundError from extensions.ext_application_services import application_services from extensions.ext_database import db from libs.login import current_account_with_tenant from models import App, AppMode -from models.agent import AgentScope +from models.agent import Agent, AgentScope from services.app_service import AppService __all__ = [ "agent_manage_required_for_agent_app", + "enforce_agent_manage_or_app_scene", "get_app_model", "get_previewable_app_model", "with_session", @@ -57,43 +59,116 @@ def _load_previewable_app_model(session: Session, app_id: str) -> App | None: return AppService.get_normal_app_by_id(app_id, session) -def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]: - """Gate generic app management routes that target an Agent App. +def _agent_app_binding(app_id: str) -> Agent | None: + app_model = _load_app_model_from_scoped_session(app_id) + if app_model is None: + return None + return app_model.agent_app_binding_with_session(session=db.session(), include_archived=True) - A hidden workflow-only backing App only reuses the App runtime and is not - part of the general app management plane, so generic routes reject it - outright. Managing a roster Agent App mutates the roster Agent behind it - (rename/icon sync, archive, API enablement), so it additionally requires - workspace ``agent.manage`` on top of the route's existing App permission - checks when RBAC is enabled. A no-op for non-agent Apps. Must be placed - above ``get_app_model`` so the ``app_id`` path parameter is still present. - """ - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs) -> R: - raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id") - if raw_app_id is not None: - app_model = _load_app_model_from_scoped_session(str(raw_app_id)) - binding = ( - app_model.agent_app_binding_with_session(session=db.session(), include_archived=True) - if app_model is not None - else None +def _reject_hidden_agent_backing_app(path_args: dict[str, object]) -> None: + raw_app_id = path_args.get("app_id") or path_args.get("resource_id") + if raw_app_id is None: + return + binding = _agent_app_binding(str(raw_app_id)) + if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY: + raise AppNotFoundError() + + +def enforce_agent_manage_or_app_scene( + *, + tenant_id: str, + account_id: str, + scene: RBACPermission, + path_args: dict[str, object], +) -> None: + # Must run before the RBAC_ENABLED check below: a hidden workflow-only + # backing App has to stay unreachable regardless of RBAC_ENABLED. + _reject_hidden_agent_backing_app(path_args) + + if not dify_config.RBAC_ENABLED: + return + + binding = _agent_app_binding(_extract_resource_id(RBACResourceScope.APP, tenant_id, path_args)) + + if binding is not None: + if binding.scope == AgentScope.WORKFLOW_ONLY: + raise AppNotFoundError() + try: + enforce_rbac_access( + tenant_id=tenant_id, + account_id=account_id, + resource_type=RBACResourceScope.WORKSPACE, + scene=RBACPermission.AGENT_MANAGE, + resource_required=False, ) - if binding is not None: - if binding.scope == AgentScope.WORKFLOW_ONLY: - raise AppNotFoundError() - if dify_config.RBAC_ENABLED: - current_user, current_tenant_id = current_account_with_tenant() - enforce_rbac_access( - tenant_id=current_tenant_id, - account_id=current_user.id, - resource_type=RBACResourceScope.WORKSPACE, - scene=RBACPermission.AGENT_MANAGE, - resource_required=False, - ) - return view(*args, **kwargs) + return + except Forbidden: + pass # not an agent.manage holder — fall through to the normal scene check - return decorated + enforce_rbac_access( + tenant_id=tenant_id, + account_id=account_id, + resource_type=RBACResourceScope.APP, + scene=scene, + path_args=path_args, + ) + + +@overload +def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]: ... + + +@overload +def agent_manage_required_for_agent_app[**P, R]( + view: None = None, *, scene: RBACPermission | None = None +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + +def agent_manage_required_for_agent_app[**P, R]( + view: Callable[P, R] | None = None, *, scene: RBACPermission | None = None +) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: + # Must sit above get_app_model in the decorator stack — get_app_model + # deletes app_id from kwargs, and this decorator needs it. + # TODO: this is a workaround, remove this after ACL for agent app is available + def decorator(view_func: Callable[P, R]) -> Callable[P, R]: + @wraps(view_func) + def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + if scene is not None: + if not dify_config.RBAC_ENABLED: + _reject_hidden_agent_backing_app(kwargs) + return view_func(*args, **kwargs) + current_user, current_tenant_id = current_account_with_tenant() + enforce_agent_manage_or_app_scene( + tenant_id=current_tenant_id, + account_id=current_user.id, + scene=scene, + path_args=kwargs, + ) + return view_func(*args, **kwargs) + + raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id") + if raw_app_id is not None: + binding = _agent_app_binding(str(raw_app_id)) + if binding is not None: + if binding.scope == AgentScope.WORKFLOW_ONLY: + raise AppNotFoundError() + if dify_config.RBAC_ENABLED: + current_user, current_tenant_id = current_account_with_tenant() + enforce_rbac_access( + tenant_id=current_tenant_id, + account_id=current_user.id, + resource_type=RBACResourceScope.WORKSPACE, + scene=RBACPermission.AGENT_MANAGE, + resource_required=False, + ) + return view_func(*args, **kwargs) + + return decorated + + if view is None: + return decorator + return decorator(view) def _get_injected_session(args: tuple[object, ...]) -> Session | None: diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index 9a8784d543a..a4d6bc4a090 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -25,7 +25,7 @@ from services.entities.auth_entities import ( ForgotPasswordResetPayload, ForgotPasswordSendPayload, ) -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class ForgotPasswordEmailResponse(BaseModel): @@ -87,7 +87,7 @@ class ForgotPasswordSendEmailApi(Resource): account=account, email=normalized_email, language=language, - is_allow_register=FeatureService.get_system_features().is_allow_register, + is_allow_register=SystemFeatureService.is_registration_allowed(), ) return {"result": "success", "data": token} @@ -198,6 +198,6 @@ class ForgotPasswordResetApi(Resource): # Create workspace if needed if ( not TenantService.get_join_tenants(account, session=db.session()) - and FeatureService.is_workspace_creation_allowed() + and SystemFeatureService.is_workspace_creation_allowed() ): TenantService.create_owner_tenant(account, session=db.session()) diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 14b51df7dcc..4a06243877c 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -81,7 +81,7 @@ from services.errors.account import ( EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService from services.turnstile_service import ( EMAIL_CODE_VERIFY_ACTION, TurnstileChallengeRejectedError, @@ -201,8 +201,8 @@ class LoginApi(Resource): tenants = TenantService.get_join_tenants(account, session=db.session()) if len(tenants) == 0: if ( - FeatureService.is_workspace_creation_allowed() - and not FeatureService.get_license().workspaces.is_available() + SystemFeatureService.is_workspace_creation_allowed() + and not SystemFeatureService.get_license().workspaces.is_available() ): raise WorkspacesLimitExceeded() else: @@ -272,7 +272,7 @@ class ResetPasswordSendEmailApi(Resource): email=normalized_email, account=account, language=language, - is_allow_register=FeatureService.get_system_features().is_allow_register, + is_allow_register=SystemFeatureService.is_registration_allowed(), ) return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @@ -313,7 +313,7 @@ class EmailCodeLoginSendEmailApi(Resource): raise AccountInFreezeError() from exc if account is None: - if FeatureService.get_system_features().is_allow_register: + if SystemFeatureService.is_registration_allowed(): token = AccountService.send_email_code_login_email(email=normalized_email, language=language) else: raise AccountNotFound() @@ -398,10 +398,10 @@ class EmailCodeLoginApi(Resource): if account: tenants = TenantService.get_join_tenants(account, session=db.session()) if not tenants: - workspaces = FeatureService.get_license().workspaces + workspaces = SystemFeatureService.get_license().workspaces if not workspaces.is_available(): raise WorkspacesLimitExceeded() - if not FeatureService.is_workspace_creation_allowed(): + if not SystemFeatureService.is_workspace_creation_allowed(): raise NotAllowedCreateWorkspace() else: TenantService.create_owner_tenant(account, session=db.session()) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index e7cc10c9e06..58f13f33511 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -36,7 +36,7 @@ from services.errors.account import ( EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService from .. import console_ns @@ -310,7 +310,7 @@ def _generate_account( if account: tenants = TenantService.get_join_tenants(account, session=db.session()) if not tenants: - if not FeatureService.is_workspace_creation_allowed(): + if not SystemFeatureService.is_workspace_creation_allowed(): raise WorkSpaceNotAllowedCreateError() else: TenantService.create_owner_tenant(account, session=db.session()) @@ -318,7 +318,7 @@ def _generate_account( if not account: normalized_email = user_info.email.lower() oauth_new_user = True - if not FeatureService.get_system_features().is_allow_register: + if not SystemFeatureService.is_registration_allowed(): if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: freeze_type = BillingService.get_email_freeze_type(normalized_email) if freeze_type: diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py index 3bbbecaecfd..094adc42022 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py @@ -17,7 +17,7 @@ from controllers.console.wraps import ( with_current_user, ) from extensions.ext_database import db -from fields.dataset_fields import DatasetDetailResponse +from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source from libs.helper import dump_response from libs.login import login_required from models import Account @@ -116,6 +116,7 @@ class CreateEmptyRagPipelineDatasetApi(Resource): # The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator if not current_user.is_dataset_editor: raise Forbidden() + session = db.session() dataset = DatasetService.create_empty_rag_pipeline_dataset( tenant_id=current_tenant_id, rag_pipeline_dataset_create_entity=RagPipelineDatasetCreateEntity( @@ -129,6 +130,6 @@ class CreateEmptyRagPipelineDatasetApi(Resource): permission=DatasetPermissionEnum.ONLY_ME, partial_member_list=None, ), - session=db.session(), + session=session, ) - return dump_response(DatasetDetailResponse, dataset), 201 + return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201 diff --git a/api/controllers/console/explore/wraps.py b/api/controllers/console/explore/wraps.py index a09341f649d..872c48538c3 100644 --- a/api/controllers/console/explore/wraps.py +++ b/api/controllers/console/explore/wraps.py @@ -18,7 +18,7 @@ from extensions.ext_database import db from libs.login import current_account_with_tenant, login_required from models import AccountTrialAppRecord, App, InstalledApp, TrialApp from services.enterprise.enterprise_service import EnterpriseService -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None): @@ -55,8 +55,7 @@ def user_allowed_to_access_app[**P, R](view: Callable[Concatenate[InstalledApp, @wraps(view) def decorated(installed_app: InstalledApp, *args: P.args, **kwargs: P.kwargs): current_user, _ = current_account_with_tenant() - feature = FeatureService.get_system_features() - if feature.webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): app_id = installed_app.app_id res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp( user_id=str(current_user.id), diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index edc9a9f19ab..c735420c476 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -125,7 +125,7 @@ class SystemFeatureApi(Resource): Authentication configuration must be available before the authentication flow can be selected. Authenticated license detail is served separately by SystemFeatureLicenseApi. """ - return dump_response(SystemFeatureModel, application_services().feature_queries.get_system_features()) + return dump_response(SystemFeatureModel, application_services().feature_queries.get_public_system_features()) @console_ns.route("/system-features/license") diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index eb300128aed..b8e3cc1ce16 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -9,6 +9,7 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.wraps import enforce_rbac_access +from controllers.console.app.wraps import enforce_agent_manage_or_app_scene from controllers.console.wraps import ( account_initialization_required, enable_change_email, @@ -22,7 +23,7 @@ from libs.login import current_account_with_tenant, login_required from machinery.context import RequestContext from machinery.errors import AdmissionConfigurationError from models.account import TenantAccountRole -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService def console_email_registration_admission[T, **P, R]( @@ -32,8 +33,10 @@ def console_email_registration_admission[T, **P, R]( @wraps(view) def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: - features = FeatureService.get_system_features() - if not features.enable_email_password_login or not features.is_allow_register: + if ( + not SystemFeatureService.is_email_password_login_enabled() + or not SystemFeatureService.is_registration_allowed() + ): abort(403) return view(self, *args, **kwargs) @@ -50,6 +53,7 @@ def console_account_admission[T, **P, R]( rbac_resource_scope: RBACResourceScope | None = None, rbac_permission: RBACPermission | None = None, rbac_resource_required: bool = True, + agent_manage_fallback: bool = False, ) -> Callable[ [Callable[Concatenate[T, RequestContext, P], R]], Callable[Concatenate[T, P], R | Response], @@ -64,6 +68,10 @@ def console_account_admission[T, **P, R]( if (rbac_resource_scope is None) != (rbac_permission is None): raise AdmissionConfigurationError("RBAC resource scope and permission must be configured together") + if agent_manage_fallback and rbac_resource_scope != RBACResourceScope.APP: + raise AdmissionConfigurationError("agent_manage_fallback requires rbac_resource_scope=RBACResourceScope.APP") + if agent_manage_fallback and not rbac_resource_required: + raise AdmissionConfigurationError("agent_manage_fallback requires rbac_resource_required=True") def decorator( view: Callable[Concatenate[T, RequestContext, P], R], @@ -76,14 +84,22 @@ def console_account_admission[T, **P, R]( if allowed_roles is not None and not dify_config.RBAC_ENABLED and account.role not in allowed_roles: raise Forbidden() if rbac_resource_scope is not None and rbac_permission is not None: - enforce_rbac_access( - tenant_id=tenant_id, - account_id=account.id, - resource_type=rbac_resource_scope, - scene=rbac_permission, - resource_required=rbac_resource_required, - path_args=kwargs, - ) + if agent_manage_fallback: + enforce_agent_manage_or_app_scene( + tenant_id=tenant_id, + account_id=account.id, + scene=rbac_permission, + path_args=kwargs, + ) + else: + enforce_rbac_access( + tenant_id=tenant_id, + account_id=account.id, + resource_type=rbac_resource_scope, + scene=rbac_permission, + resource_required=rbac_resource_required, + path_args=kwargs, + ) request_context = RequestContext( account_id=account.id, active_workspace_id=tenant_id, diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index ebc30c9fa59..f32bc47cc42 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -45,6 +45,7 @@ from models.account import Account, TenantAccountJoin, TenantAccountRole from services.account_service import AccountService, RegisterService, TenantService from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class MemberInvitePayload(BaseModel): @@ -185,7 +186,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou if workspace_members.enabled is True and not workspace_members.is_available(new_member_count): raise WorkspaceMembersLimitExceeded() if new_account_count > 0: - seats = FeatureService.get_license().seats + seats = SystemFeatureService.get_license().seats if not seats.is_available(new_account_count): raise SeatsLimitExceeded() return diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index ca0b536f85e..fe844204d27 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -1,7 +1,7 @@ import io from typing import Any, Literal -from flask import request, send_file +from flask import send_file from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import Session @@ -15,6 +15,7 @@ from controllers.console.wraps import ( RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + model_validate, rbac_permission_required, setup_required, with_current_tenant_id, @@ -154,10 +155,8 @@ class ModelProviderListApi(Resource): @login_required @account_initialization_required @with_current_tenant_id - def get(self, tenant_id: str): - payload = request.args.to_dict(flat=True) - args = ParserModelList.model_validate(payload) - + @model_validate(ParserModelList) + def get(self, args: ParserModelList, tenant_id: str): model_provider_service = ModelProviderService() provider_list = model_provider_service.get_provider_list(tenant_id=tenant_id, model_type=args.model_type) @@ -212,12 +211,10 @@ class ModelProviderCredentialApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id - def get(self, tenant_id: str, provider: str): - # if credential_id is not provided, return current used credential - payload = request.args.to_dict(flat=True) - args = ParserCredentialId.model_validate(payload) - + @model_validate(ParserCredentialId) + def get(self, args: ParserCredentialId, tenant_id: str, provider: str): model_provider_service = ModelProviderService() + # if credential_id is not provided, return current used credential credentials = model_provider_service.get_provider_credential( tenant_id=tenant_id, provider=provider, credential_id=args.credential_id ) @@ -232,10 +229,8 @@ class ModelProviderCredentialApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False) @account_initialization_required @with_current_tenant_id - def post(self, current_tenant_id: str, provider: str): - payload = console_ns.payload or {} - args = ParserCredentialCreate.model_validate(payload) - + @model_validate(ParserCredentialCreate) + def post(self, args: ParserCredentialCreate, current_tenant_id: str, provider: str): model_provider_service = ModelProviderService() try: @@ -258,10 +253,8 @@ class ModelProviderCredentialApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id - def put(self, current_tenant_id: str, provider: str): - payload = console_ns.payload or {} - args = ParserCredentialUpdate.model_validate(payload) - + @model_validate(ParserCredentialUpdate) + def put(self, args: ParserCredentialUpdate, current_tenant_id: str, provider: str): model_provider_service = ModelProviderService() try: @@ -285,10 +278,8 @@ class ModelProviderCredentialApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id - def delete(self, current_tenant_id: str, provider: str): - payload = console_ns.payload or {} - args = ParserCredentialDelete.model_validate(payload) - + @model_validate(ParserCredentialDelete) + def delete(self, args: ParserCredentialDelete, current_tenant_id: str, provider: str): model_provider_service = ModelProviderService() model_provider_service.remove_provider_credential( tenant_id=current_tenant_id, provider=provider, credential_id=args.credential_id @@ -307,10 +298,8 @@ class ModelProviderCredentialSwitchApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False) @account_initialization_required @with_current_tenant_id - def post(self, current_tenant_id: str, provider: str): - payload = console_ns.payload or {} - args = ParserCredentialSwitch.model_validate(payload) - + @model_validate(ParserCredentialSwitch) + def post(self, args: ParserCredentialSwitch, current_tenant_id: str, provider: str): service = ModelProviderService() service.switch_active_provider_credential( tenant_id=current_tenant_id, @@ -332,10 +321,8 @@ class ModelProviderValidateApi(Resource): @login_required @account_initialization_required @with_current_tenant_id - def post(self, current_tenant_id: str, provider: str): - payload = console_ns.payload or {} - args = ParserCredentialValidate.model_validate(payload) - + @model_validate(ParserCredentialValidate) + def post(self, args: ParserCredentialValidate, current_tenant_id: str, provider: str): tenant_id = current_tenant_id model_provider_service = ModelProviderService() @@ -388,10 +375,8 @@ class PreferredProviderTypeUpdateApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False) @account_initialization_required @with_current_tenant_id - def post(self, tenant_id: str, provider: str): - payload = console_ns.payload or {} - args = ParserPreferredProviderType.model_validate(payload) - + @model_validate(ParserPreferredProviderType) + def post(self, args: ParserPreferredProviderType, tenant_id: str, provider: str): model_provider_service = ModelProviderService() model_provider_service.switch_preferred_provider( tenant_id=tenant_id, provider=provider, preferred_provider_type=args.preferred_provider_type diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index da1d5584af6..dd681a3b5e5 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -19,6 +19,7 @@ from controllers.common.wraps import ( from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError from controllers.console.workspace.error import AccountNotInitializedError from enums import CloudPlan, DeploymentEdition +from extensions.ext_application_services import application_services from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.encryption import FieldEncryption @@ -31,6 +32,7 @@ from services.billing_service import BillingService from services.entities.feature_entities import LicenseStatus from services.feature_service import FeatureService from services.operation_service import OperationService, UtmInfo +from services.system_feature_service import SystemFeatureService from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout @@ -183,7 +185,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return view(*args, **kwargs) - vector_space = FeatureService.get_vector_space(current_tenant_id) + vector_space = application_services().feature_queries.get_workspace_vector_space(current_tenant_id) if 0 < vector_space.limit <= vector_space.size: abort( 403, @@ -330,8 +332,11 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]: def enterprise_license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - settings = FeatureService.get_system_features() - if settings.license.status in [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]: + if SystemFeatureService.get_license_status() in [ + LicenseStatus.INACTIVE, + LicenseStatus.EXPIRED, + LicenseStatus.LOST, + ]: raise UnauthorizedAndForceLogout("Your license is invalid. Please contact your administrator.") return view(*args, **kwargs) @@ -342,8 +347,7 @@ def enterprise_license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - features = FeatureService.get_system_features() - if features.enable_email_password_login: + if SystemFeatureService.is_email_password_login_enabled(): return view(*args, **kwargs) # otherwise, return 403 @@ -355,8 +359,7 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R] def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - features = FeatureService.get_system_features() - if features.enable_change_email: + if SystemFeatureService.is_change_email_enabled(): return view(*args, **kwargs) # otherwise, return 403 @@ -372,7 +375,11 @@ def is_allow_transfer_owner[**P, R](view: Callable[P, R]) -> Callable[P, R]: _, current_tenant_id = current_account_with_tenant() # Check both billing/plan level and workspace policy level permissions - check_workspace_owner_transfer_permission(current_tenant_id) + features = application_services().feature_queries.get_workspace_features(current_tenant_id) + check_workspace_owner_transfer_permission( + current_tenant_id, + owner_transfer_allowed=features.is_allow_transfer_workspace, + ) return view(*args, **kwargs) return decorated diff --git a/api/controllers/inner_api/mail.py b/api/controllers/inner_api/mail.py index 885ab7b78d4..353cfc9dda5 100644 --- a/api/controllers/inner_api/mail.py +++ b/api/controllers/inner_api/mail.py @@ -6,8 +6,9 @@ from pydantic import BaseModel, Field from controllers.common.schema import register_schema_model from controllers.console.wraps import setup_required from controllers.inner_api import inner_api_ns -from controllers.inner_api.wraps import billing_inner_api_only, enterprise_inner_api_only -from tasks.mail_inner_task import send_inner_email_task +from controllers.inner_api.wraps import inner_api_only +from extensions.ext_application_services import application_services +from services.entities.mail_entities import InnerMailMessage class InnerMailPayload(BaseModel): @@ -28,25 +29,27 @@ class BaseMail(Resource): @inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__]) def post(self): args = InnerMailPayload.model_validate(inner_api_ns.payload or {}) - send_inner_email_task.delay( - to=args.to, - subject=args.subject, - body=args.body, - substitutions=args.substitutions, # type: ignore + application_services().inner_mail.send( + InnerMailMessage( + recipients=tuple(args.to), + subject=args.subject, + body=args.body, + substitutions=args.substitutions, + ) ) return {"message": "success"}, 200 @inner_api_ns.route("/enterprise/mail") class EnterpriseMail(BaseMail): - method_decorators = [setup_required, enterprise_inner_api_only] - @inner_api_ns.doc("send_enterprise_mail") @inner_api_ns.doc(description="Send internal email for enterprise features") @inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__]) @inner_api_ns.doc( responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"} ) + @inner_api_only + @setup_required def post(self): """Send internal email for enterprise features. @@ -61,14 +64,14 @@ class EnterpriseMail(BaseMail): @inner_api_ns.route("/billing/mail") class BillingMail(BaseMail): - method_decorators = [setup_required, billing_inner_api_only] - @inner_api_ns.doc("send_billing_mail") @inner_api_ns.doc(description="Send internal email for billing notifications") @inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__]) @inner_api_ns.doc( responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"} ) + @inner_api_only + @setup_required def post(self): """Send internal email for billing notifications. diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index 5dd88392a9c..764cd36d08d 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -35,10 +35,6 @@ def inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: return decorated -def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: - return inner_api_only(view) - - def enterprise_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: return inner_api_only(view) diff --git a/api/controllers/openapi/auth/conditions.py b/api/controllers/openapi/auth/conditions.py index a25eaf78aa1..49da949ccab 100644 --- a/api/controllers/openapi/auth/conditions.py +++ b/api/controllers/openapi/auth/conditions.py @@ -7,7 +7,7 @@ from controllers.openapi.auth.data import AuthData, RequestContext from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from services.enterprise.enterprise_service import WebAppAccessMode -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService CondFn = Callable[[RequestContext, AuthData | None], bool] @@ -50,7 +50,7 @@ EDITION_COMMUNITY = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == Deploy EDITION_ENTERPRISE = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE) EDITION_CLOUD = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD) -WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled) +WEBAPP_AUTH_ENABLED = config_cond(lambda: SystemFeatureService.is_webapp_auth_enabled()) WEBAPP_RUN_SCOPED = request_cond(lambda ctx: ctx.scope == Scope.APPS_RUN) diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py index f27064eda96..6a68e5c91fe 100644 --- a/api/controllers/openapi/auth/pipeline.py +++ b/api/controllers/openapi/auth/pipeline.py @@ -37,7 +37,7 @@ from libs.oauth_bearer import ( ) from models.account import TenantAccountRole from services.entities.feature_entities import LicenseStatus -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class AuthPipeline: @@ -265,8 +265,11 @@ def _subject_type_str(identity: Any) -> str | None: def _check_license() -> None: - settings = FeatureService.get_system_features() - if settings.license.status in {LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST}: + if SystemFeatureService.get_license_status() in { + LicenseStatus.INACTIVE, + LicenseStatus.EXPIRED, + LicenseStatus.LOST, + }: raise Forbidden("license_invalid") diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index 832190e08b2..0b54d133661 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -1,7 +1,6 @@ from typing import Literal from uuid import UUID -from flask import request from flask_restx import Resource from flask_restx.api import HTTPStatus from pydantic import BaseModel, Field, TypeAdapter @@ -207,9 +206,9 @@ class AnnotationListApi(Resource): ) @validate_app_token @with_session(write=False) - def get(self, session: Session, app_model: App): + @model_validate(AnnotationListQuery) + def get(self, query: AnnotationListQuery, session: Session, app_model: App): """List annotations for the application.""" - query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id( app_model.id, query.page, query.limit, query.keyword, session diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index 163a50e959b..124da072be9 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import Annotated, Any, Literal from uuid import UUID -from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema, field_validator from sqlalchemy.orm import sessionmaker @@ -185,7 +184,8 @@ class ConversationApi(Resource): service_api_ns.models[ConversationInfiniteScrollPagination.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) - def get(self, app_model: App, end_user: EndUser): + @model_validate(ConversationListQuery) + def get(self, query_args: ConversationListQuery, app_model: App, end_user: EndUser): """List all conversations for the current user. Supports pagination using last_id and limit parameters. @@ -194,7 +194,6 @@ class ConversationApi(Resource): if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: raise NotChatAppError() - query_args = ConversationListQuery.model_validate(request.args.to_dict()) last_id = query_args.last_id or None try: @@ -343,7 +342,8 @@ class ConversationVariablesApi(Resource): service_api_ns.models[ConversationVariableInfiniteScrollPaginationResponse.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) - def get(self, app_model: App, end_user: EndUser, conversation_id: UUID): + @model_validate(ConversationVariablesQuery) + def get(self, query_args: ConversationVariablesQuery, app_model: App, end_user: EndUser, conversation_id: UUID): """List all variables for a conversation. Conversational variables are only available for chat applications. @@ -355,7 +355,6 @@ class ConversationVariablesApi(Resource): conversation_id_str = str(conversation_id) - query_args = ConversationVariablesQuery.model_validate(request.args.to_dict()) last_id = query_args.last_id or None try: diff --git a/api/controllers/service_api/app/file_preview.py b/api/controllers/service_api/app/file_preview.py index b315e190fa3..ad96ac96ce1 100644 --- a/api/controllers/service_api/app/file_preview.py +++ b/api/controllers/service_api/app/file_preview.py @@ -2,7 +2,7 @@ import logging from urllib.parse import quote from uuid import UUID -from flask import Response, request +from flask import Response from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy import select @@ -10,6 +10,7 @@ from sqlalchemy import select from controllers.common.fields import BinaryFileResponse from controllers.common.file_response import enforce_download_for_html from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_model +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( FileAccessDeniedError, @@ -86,7 +87,8 @@ class FilePreviewApi(Resource): ) @service_api_ns.response(200, "File retrieved successfully") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) - def get(self, app_model: App, end_user: EndUser, file_id: UUID): + @model_validate(FilePreviewQuery) + def get(self, args: FilePreviewQuery, app_model: App, end_user: EndUser, file_id: UUID): """ Preview/Download a file that was uploaded via Service API. @@ -95,9 +97,6 @@ class FilePreviewApi(Resource): """ file_id_str = str(file_id) - # Parse query parameters - args = FilePreviewQuery.model_validate(request.args.to_dict()) - # Validate file ownership and get file objects _, upload_file = self._validate_file_ownership(file_id_str, app_model.id) diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index d3443371313..63bf87709d9 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -2,7 +2,6 @@ import logging from typing import Annotated from uuid import UUID -from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema from werkzeug.exceptions import BadRequest, InternalServerError, NotFound @@ -102,7 +101,8 @@ class MessageListApi(Resource): service_api_ns.models[MessageInfiniteScrollPagination.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) - def get(self, app_model: App, end_user: EndUser): + @model_validate(MessageListQuery) + def get(self, query_args: MessageListQuery, app_model: App, end_user: EndUser): """List messages in a conversation. Retrieves messages with pagination support using first_id. @@ -111,7 +111,6 @@ class MessageListApi(Resource): if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: raise NotChatAppError() - query_args = MessageListQuery.model_validate(request.args.to_dict()) conversation_id = query_args.conversation_id first_id = query_args.first_id or None @@ -212,12 +211,12 @@ class AppGetFeedbacksApi(Resource): service_api_ns.models[AppFeedbackListResponse.__name__], ) @validate_app_token - def get(self, app_model: App): + @model_validate(FeedbackListQuery) + def get(self, query_args: FeedbackListQuery, app_model: App): """Get all feedbacks for the application. Returns paginated list of all feedback submitted for messages in this app. """ - query_args = FeedbackListQuery.model_validate(request.args.to_dict()) feedbacks = MessageService.get_all_messages_feedbacks( app_model, page=query_args.page, limit=query_args.limit, session=db.session() ) diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 09ce95942dd..88d865def14 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -23,6 +23,7 @@ from controllers.service_api.schema import ( USER_REQUIRED_ATTR, ) from enums import CloudPlan, DeploymentEdition +from extensions.ext_application_services import application_services from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.login import current_user @@ -196,7 +197,7 @@ def cloud_edition_billing_resource_check[**P, R]( if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return view(*args, **kwargs) - vector_space = FeatureService.get_vector_space(api_token.tenant_id) + vector_space = application_services().feature_queries.get_workspace_vector_space(api_token.tenant_id) if vector_space.usage_unknown: features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True) if features.billing.enabled and features.billing.subscription.plan == CloudPlan.SANDBOX: diff --git a/api/controllers/web/feature.py b/api/controllers/web/feature.py index fcaaac98e28..6e07d9470cd 100644 --- a/api/controllers/web/feature.py +++ b/api/controllers/web/feature.py @@ -32,5 +32,5 @@ class SystemFeatureApi(Resource): """ return dump_response( SystemFeatureModel, - application_services().feature_queries.get_system_features(), + application_services().feature_queries.get_public_system_features(), ) diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 4b0b25fb971..3e4a7960984 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -1,27 +1,22 @@ -import uuid -from datetime import UTC, datetime, timedelta -from typing import Any - from flask import request from flask_restx import Resource from pydantic import BaseModel, Field -from sqlalchemy import func, select from werkzeug.exceptions import NotFound, Unauthorized -from configs import dify_config from constants import HEADER_NAME_APP_CODE from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import WebAppAuthRequiredError -from extensions.ext_database import db +from extensions.ext_application_services import application_services from fields.base import ResponseModel from libs.helper import dump_response -from libs.passport import PassportService from libs.token import extract_webapp_access_token -from models.enums import EndUserType -from models.model import App, EndUser, Site -from services.feature_service import FeatureService -from services.webapp_auth_service import WebAppAuthService, WebAppAuthType +from services.entities.passport_entities import WebPassportRequest +from services.web_passport_service import ( + WebPassportAuthenticationRequiredError, + WebPassportNotFoundError, + WebPassportUnauthorizedError, +) class PassportQuery(BaseModel): @@ -40,7 +35,7 @@ register_response_schema_models(web_ns, PassportAccessTokenResponse) @web_ns.route("/passport") class PassportResource(Resource): - """Base resource for passport.""" + """Issue an authentication passport for a deployed web application.""" @web_ns.doc("get_passport") @web_ns.doc(description="Get authentication passport for web application access") @@ -54,207 +49,23 @@ class PassportResource(Resource): ) @web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__]) def get(self): - system_features = FeatureService.get_system_features() app_code = request.headers.get(HEADER_NAME_APP_CODE) - user_id = request.args.get("user_id") - access_token = extract_webapp_access_token(request) if app_code is None: raise Unauthorized("X-App-Code header is missing.") - if system_features.webapp_auth.enabled: - enterprise_user_decoded = decode_enterprise_webapp_user_id(access_token) - app_auth_type = WebAppAuthService.get_app_auth_type(app_code=app_code, session=db.session()) - if app_auth_type != WebAppAuthType.PUBLIC: - if not enterprise_user_decoded: - raise WebAppAuthRequiredError() - return dump_response( - PassportAccessTokenResponse, - exchange_token_for_existing_web_user( - app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type - ), - ) - # get site from db and check if it is normal - site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal")) - if not site: - raise NotFound() - # get app from db and check if it is normal and enable_site - app_model = db.session.scalar(select(App).where(App.id == site.app_id)) - if not app_model or app_model.status != "normal" or not app_model.enable_site: - raise NotFound() - - if user_id: - end_user = db.session.scalar( - select(EndUser).where(EndUser.app_id == app_model.id, EndUser.session_id == user_id) - ) - - if end_user: - pass - else: - end_user = EndUser( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - type=EndUserType.BROWSER, - is_anonymous=True, - session_id=user_id, - ) - db.session.add(end_user) - db.session.commit() - else: - end_user = EndUser( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - type=EndUserType.BROWSER, - is_anonymous=True, - session_id=generate_session_id(), - ) - db.session.add(end_user) - db.session.commit() - - payload = { - "iss": site.app_id, - "sub": "Web API Passport", - "app_id": site.app_id, - "app_code": app_code, - "end_user_id": end_user.id, - } - - tk = PassportService().issue(payload) - - return dump_response(PassportAccessTokenResponse, {"access_token": tk}) - - -def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None: - """ - Decode the enterprise user session from the Authorization header. - """ - if not jwt_token: - return None - - decoded: dict[str, Any] = PassportService().verify(jwt_token) - source = decoded.get("token_source") - if not source or source != "webapp_login_token": - raise Unauthorized("Invalid token source. Expected 'webapp_login_token'.") - return decoded - - -def exchange_token_for_existing_web_user( - app_code: str, enterprise_user_decoded: dict[str, Any], auth_type: WebAppAuthType -): - """ - Exchange a token for an existing web user session. - """ - user_id = enterprise_user_decoded.get("user_id") - end_user_id = enterprise_user_decoded.get("end_user_id") - session_id = enterprise_user_decoded.get("session_id") - user_auth_type = enterprise_user_decoded.get("auth_type") - exchanged_token_expires_unix = enterprise_user_decoded.get("exp") - - if not user_auth_type: - raise Unauthorized("Missing auth_type in the token.") - - site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal")) - if not site: - raise NotFound() - - app_model = db.session.scalar(select(App).where(App.id == site.app_id)) - if not app_model or app_model.status != "normal" or not app_model.enable_site: - raise NotFound() - - match auth_type: - case WebAppAuthType.PUBLIC: - return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded) - case WebAppAuthType.EXTERNAL: - if user_auth_type != "external": - raise WebAppAuthRequiredError("Please login as external user.") - case WebAppAuthType.INTERNAL: - if user_auth_type != "internal": - raise WebAppAuthRequiredError("Please login as internal user.") - - end_user = None - if end_user_id: - end_user = db.session.scalar(select(EndUser).where(EndUser.id == end_user_id)) - if session_id: - end_user = db.session.scalar( - select(EndUser).where( - EndUser.session_id == session_id, - EndUser.tenant_id == app_model.tenant_id, - EndUser.app_id == app_model.id, - ) + query = PassportQuery.model_validate(request.args.to_dict(flat=True)) + passport_request = WebPassportRequest( + app_code=app_code, + user_session_id=query.user_id, + access_token=extract_webapp_access_token(request), ) - if not end_user: - if not session_id: - raise NotFound("Missing session_id for existing web user.") - end_user = EndUser( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - type=EndUserType.BROWSER, - is_anonymous=True, - session_id=session_id, - ) - db.session.add(end_user) - db.session.commit() + try: + result = application_services().web_passport.issue(passport_request) + except WebPassportAuthenticationRequiredError as exc: + raise WebAppAuthRequiredError(str(exc)) from exc + except WebPassportUnauthorizedError as exc: + raise Unauthorized(str(exc)) from exc + except WebPassportNotFoundError as exc: + raise NotFound(str(exc) or None) from exc - exp = int((datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp()) - if exchanged_token_expires_unix: - exp = int(exchanged_token_expires_unix) - - payload = { - "iss": site.id, - "sub": "Web API Passport", - "app_id": site.app_id, - "app_code": site.code, - "user_id": user_id, - "end_user_id": end_user.id, - "auth_type": user_auth_type, - "granted_at": int(datetime.now(UTC).timestamp()), - "token_source": "webapp", - "exp": exp, - } - token: str = PassportService().issue(payload) - return {"access_token": token} - - -def _exchange_for_public_app_token(app_model, site, token_decoded): - user_id = token_decoded.get("user_id") - end_user = None - if user_id: - end_user = db.session.scalar( - select(EndUser).where(EndUser.app_id == app_model.id, EndUser.session_id == user_id) - ) - - if not end_user: - end_user = EndUser( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - type=EndUserType.BROWSER, - is_anonymous=True, - session_id=generate_session_id(), - ) - - db.session.add(end_user) - db.session.commit() - - payload = { - "iss": site.app_id, - "sub": "Web API Passport", - "app_id": site.app_id, - "app_code": site.code, - "end_user_id": end_user.id, - } - - tk = PassportService().issue(payload) - - return {"access_token": tk} - - -def generate_session_id(): - """ - Generate a unique session ID. - """ - while True: - session_id = str(uuid.uuid4()) - existing_count = db.session.scalar( - select(func.count()).select_from(EndUser).where(EndUser.session_id == session_id) - ) - if existing_count == 0: - return session_id + return dump_response(PassportAccessTokenResponse, {"access_token": result.access_token}) diff --git a/api/controllers/web/wraps.py b/api/controllers/web/wraps.py index cf2da497176..69e0edb059f 100644 --- a/api/controllers/web/wraps.py +++ b/api/controllers/web/wraps.py @@ -18,7 +18,7 @@ from libs.token import extract_webapp_passport from models.model import App, EndUser, Site from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService from services.webapp_auth_service import WebAppAuthService @@ -44,7 +44,7 @@ def validate_jwt_token[**P, R]( def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> tuple[App, EndUser]: - system_features = FeatureService.get_system_features() + webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled() if not app_code: app_code = str(request.headers.get(HEADER_NAME_APP_CODE)) try: @@ -75,21 +75,19 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> # for enterprise webapp auth app_web_auth_enabled = False webapp_settings = None - if system_features.webapp_auth.enabled: + if webapp_auth_enabled: app_id = AppService.get_app_id_by_code(app_code, session=db.session()) webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id) if not webapp_settings: raise NotFound("Web app settings not found.") app_web_auth_enabled = webapp_settings.access_mode != WebAppAccessMode.PUBLIC - _validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled) - _validate_user_accessibility( - decoded, app_code, app_web_auth_enabled, system_features.webapp_auth.enabled, webapp_settings - ) + _validate_webapp_token(decoded, app_web_auth_enabled, webapp_auth_enabled) + _validate_user_accessibility(decoded, app_code, app_web_auth_enabled, webapp_auth_enabled, webapp_settings) return app_model, end_user except Unauthorized as e: - if system_features.webapp_auth.enabled: + if webapp_auth_enabled: if not app_code: raise Unauthorized("Please re-login to access the web app.") app_id = AppService.get_app_id_by_code(app_code, session=db.session()) diff --git a/api/core/helper/credential_utils.py b/api/core/helper/credential_utils.py index 19a62c43766..6fce1058dea 100644 --- a/api/core/helper/credential_utils.py +++ b/api/core/helper/credential_utils.py @@ -69,9 +69,9 @@ def check_credential_policy_compliance( CheckCredentialPolicyComplianceRequest, PluginManagerService, ) - from services.feature_service import FeatureService + from services.system_feature_service import SystemFeatureService - if not FeatureService.is_plugin_manager_enabled() or not credential_id: + if not SystemFeatureService.is_plugin_manager_enabled() or not credential_id: return # Check if credential exists in database first (if requested) diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 0fdfd4d27b3..3a9f72483f5 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -69,7 +69,7 @@ from services.enterprise.plugin_manager_service import ( ) from services.entities.feature_entities import PluginInstallationPermissionModel, PluginInstallationScope from services.errors.plugin import PluginInstallationForbiddenError -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService logger = logging.getLogger(__name__) _provider_entities_adapter: TypeAdapter[list[PluginModelProviderDeclaration]] = TypeAdapter( @@ -667,7 +667,7 @@ class PluginService: @staticmethod def _get_plugin_installation_permission() -> PluginInstallationPermissionModel: """Resolve the validated policy and reject deny-all before any installation side effect.""" - permission = FeatureService.get_plugin_installation_permission() + permission = SystemFeatureService.get_plugin_installation_permission() if permission.plugin_installation_scope == PluginInstallationScope.NONE: raise PluginInstallationForbiddenError("Installing plugins is not allowed") return permission diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index 02ba52914ab..94666c1b59a 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -6,9 +6,17 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session +from core.agent.publish_visibility import workflow_callable_active_snapshot_filter from core.workflow.graph_topology import WorkflowGraphTopology from graphon.enums import BuiltinNodeTypes -from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding +from models.agent import ( + Agent, + AgentConfigSnapshot, + AgentScope, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) from models.agent_config_entities import ( AgentFileRefConfig, AgentHumanContactConfig, @@ -127,21 +135,28 @@ class WorkflowAgentNodeValidator: topology: _WorkflowGraphTopology | None = None, require_agent_model: bool = True, ) -> None: + """Validate binding ownership, publication state, Agent Soul, and node-job references.""" + if binding.agent_id is None: raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.") - agent = session.scalar( - select(Agent) - .where( - Agent.tenant_id == binding.tenant_id, - Agent.id == binding.agent_id, - ) - .limit(1) + agent_stmt = select(Agent).where( + Agent.tenant_id == binding.tenant_id, + Agent.id == binding.agent_id, ) - if agent is None or agent.status == AgentStatus.ARCHIVED: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references an unavailable agent." + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT: + agent_stmt = agent_stmt.where( + Agent.scope == AgentScope.ROSTER, + workflow_callable_active_snapshot_filter(), ) + agent = session.scalar(agent_stmt.limit(1)) + if agent is None or agent.status == AgentStatus.ARCHIVED: + availability = ( + "an unavailable or unpublished roster agent" + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else "an unavailable agent" + ) + raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} references {availability}.") snapshot_id = ( agent.active_config_snapshot_id diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 747c658a561..ef2cce70e87 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -3,7 +3,9 @@ import json from collections.abc import Mapping from dataclasses import dataclass +from datetime import UTC, datetime from typing import cast +from uuid import uuid4 import httpx from flask import Flask, current_app @@ -19,6 +21,7 @@ from enums import DeploymentEdition, WebAppAccessMode from extensions.ext_redis import RedisClientWrapper, redis_client from libs.datetime_utils import naive_utc_now from libs.helper import RateLimiter +from libs.passport import PassportService from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository from repositories.account_repository import SQLAlchemyAccountRepository @@ -35,6 +38,7 @@ from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourSt from repositories.tag_repository import TagRepository from repositories.trial_app_query_repository import TrialAppQueryRepository from repositories.trial_app_usage_repository import TrialAppUsageRepository +from repositories.web_passport_repository import WebPassportRepository from repositories.webapp_access_query_repository import WebAppAccessQueryRepository from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository @@ -101,10 +105,10 @@ from services.enterprise.enterprise_service import EnterpriseService from services.errors.enterprise import EnterpriseServiceError from services.explore_banner_query_service import ExploreBannerQueryService from services.feature_query_service import FeatureQueryService -from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway from services.file_service import FileService from services.init_validation_service import InitValidationService +from services.inner_mail_service import InnerMailService from services.notification_gateway import BillingNotificationGateway from services.notification_service import NotificationService from services.notion_data_source_gateway import NotionDataSourceGateway @@ -126,9 +130,15 @@ from services.schema_definition_service import SchemaDefinitionService from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner from services.setup_service import SetupService from services.step_by_step_tour_service import StepByStepTourService +from services.system_feature_service import SystemFeatureService from services.tag_application_service import TagApplicationService from services.trial_app_usage import TrialAppUsageRecorder from services.web_app_runtime_query_service import WebAppRuntimeQueryService +from services.web_passport_gateways import ( + DeploymentWebPassportAuthGateway, + PassportTokenGateway, +) +from services.web_passport_service import WebPassportService from services.webapp_access_query_service import ( WebAppAccessQueryService, WebAppAccessUnavailableError, @@ -138,6 +148,7 @@ from services.workspace_member_query_service import WorkspaceMemberQueryService from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway from services.workspace_query_service import WorkspaceQueryService +from tasks.mail_inner_task import enqueue_inner_mail _EXTENSION_KEY = "application_services" @@ -200,6 +211,8 @@ class ApplicationServices: workflow_run_archives: WorkflowRunArchiveService workspace_queries: WorkspaceQueryService workspace_member_queries: WorkspaceMemberQueryService + inner_mail: InnerMailService + web_passport: WebPassportService tags: TagApplicationService workflow_statistics: WorkflowStatisticQueryService @@ -257,7 +270,7 @@ def build_application_services( feature_gateway = FeatureServiceGateway() accounts = SQLAlchemyAccountRepository(session_factory=database_client) integrations = SQLAlchemyAccountIntegrationRepository(session_factory=database_client) - trial_app_enabled = FeatureService.is_trial_app_enabled() + trial_app_enabled = SystemFeatureService.is_trial_app_enabled() database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis) builtin_catalog = BuiltinRecommendedAppCatalogGateway() remote_catalog = RemoteRecommendedAppCatalogGateway() @@ -409,7 +422,7 @@ def build_application_services( data_source_oauth=_build_data_source_oauth_services(database_client=database_client), webapp_access=WebAppAccessQueryService( access=WebAppAccessQueryRepository(session_factory=database_client), - webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(), + webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition), access_mode_for_app=_get_enterprise_webapp_access_mode, is_user_allowed_for_app=_is_user_allowed_to_access_webapp, ), @@ -421,7 +434,7 @@ def build_application_services( ), explore_banner_queries=ExploreBannerQueryService( banners=ExploreBannerQueryRepository(session_factory=database_client), - enabled=FeatureService.is_explore_banner_enabled(), + enabled=SystemFeatureService.is_explore_banner_enabled(), ), schema_definitions=SchemaDefinitionService(source_factory=SchemaManager), setup=SetupService( @@ -475,6 +488,20 @@ def build_application_services( ), roles=DeploymentWorkspaceMemberRoleResolver(), ), + inner_mail=InnerMailService(dispatch=enqueue_inner_mail), + web_passport=WebPassportService( + passports=WebPassportRepository( + session_factory=database_client, + generate_session_id=lambda: str(uuid4()), + ), + auth=DeploymentWebPassportAuthGateway( + webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition), + get_app_access_mode=EnterpriseService.WebAppAuth.get_app_access_mode_by_id, + ), + tokens=PassportTokenGateway(passport=PassportService()), + now=lambda: datetime.now(UTC), + access_token_expire_minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES, + ), tags=TagApplicationService( tags=TagRepository(session_factory=database_client), ), diff --git a/api/libs/device_flow_security.py b/api/libs/device_flow_security.py index c10d3daaab6..1e7ec3683c3 100644 --- a/api/libs/device_flow_security.py +++ b/api/libs/device_flow_security.py @@ -18,7 +18,7 @@ from werkzeug.exceptions import NotFound from libs import jws from libs.token import is_secure from services.entities.feature_entities import LicenseStatus -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService logger = logging.getLogger(__name__) @@ -40,8 +40,7 @@ def enterprise_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - settings = FeatureService.get_system_features() - if settings.license.status not in _EE_ENABLED_STATUSES: + if SystemFeatureService.get_license_status() not in _EE_ENABLED_STATUSES: raise NotFound() return view(*args, **kwargs) diff --git a/api/libs/email_i18n.py b/api/libs/email_i18n.py index 606dd9cfde0..fd4357cd539 100644 --- a/api/libs/email_i18n.py +++ b/api/libs/email_i18n.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, Field from extensions.ext_mail import mail from services.entities.feature_entities import BrandingModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class EmailType(StrEnum): @@ -136,7 +136,7 @@ class FeatureBrandingService: def get_branding_config(self) -> BrandingModel: """Get branding configuration from feature service.""" - return FeatureService.get_system_features().branding + return SystemFeatureService.get_branding() class EmailSender(Protocol): diff --git a/api/libs/workspace_permission.py b/api/libs/workspace_permission.py index 969a81f5f9d..f6251a133af 100644 --- a/api/libs/workspace_permission.py +++ b/api/libs/workspace_permission.py @@ -3,7 +3,7 @@ Workspace permission helper functions. These helpers check both billing/plan level and workspace-specific policy level permissions. Checks are performed at two levels: -1. Billing/plan level - via FeatureService (e.g., SANDBOX plan restrictions) +1. Billing/plan level - via an injected owner-transfer policy value 2. Workspace policy level - via EnterpriseService (admin-configured per workspace) """ @@ -14,7 +14,6 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from enums import DeploymentEdition from services.enterprise.enterprise_service import EnterpriseService -from services.feature_service import FeatureService logger = logging.getLogger(__name__) @@ -45,7 +44,11 @@ def check_workspace_member_invite_permission(workspace_id: str) -> None: logger.exception("Failed to check workspace invite permission for %s", workspace_id) -def check_workspace_owner_transfer_permission(workspace_id: str) -> None: +def check_workspace_owner_transfer_permission( + workspace_id: str, + *, + owner_transfer_allowed: bool, +) -> None: """ Check if workspace allows owner transfer at both billing and policy levels. @@ -55,12 +58,12 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None: Args: workspace_id: The workspace ID to check permissions for + owner_transfer_allowed: Whether the workspace plan permits ownership transfer Raises: Forbidden: If either billing plan or workspace policy prohibits ownership transfer """ - features = FeatureService.get_features(workspace_id, exclude_vector_space=True) - if not features.is_allow_transfer_workspace: + if not owner_transfer_allowed: raise Forbidden("Your current plan does not allow workspace ownership transfer") # Check the enterprise workspace policy only in the Enterprise edition. diff --git a/api/models/dataset.py b/api/models/dataset.py index 891d949c8e6..ddcc8020ed8 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -212,17 +212,9 @@ class Dataset(Base): enable_api = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=sa.text("false")) - @property - def total_documents(self) -> int: - return self.get_total_documents(session=db.session()) - def get_total_documents(self, *, session: Session) -> int: return self.get_document_count(session=session) - @property - def total_available_documents(self) -> int: - return self.get_total_available_documents(session=db.session()) - def get_total_available_documents(self, *, session: Session) -> int: return ( session.scalar( @@ -258,20 +250,12 @@ class Dataset(Base): def get_created_by_account(self, *, session: Session) -> Account | None: return session.get(Account, self.created_by) - @property - def author_name(self) -> str | None: - return self.get_author_name(session=db.session()) - def get_author_name(self, *, session: Session) -> str | None: account = self.get_created_by_account(session=session) if account: return account.name return None - @property - def latest_process_rule(self): - return self.get_latest_process_rule(session=db.session()) - def get_latest_process_rule(self, *, session: Session) -> "DatasetProcessRule | None": return session.scalar( select(DatasetProcessRule) @@ -391,10 +375,6 @@ class Dataset(Base): return tags or [] - @property - def external_knowledge_info(self) -> dict[str, Any] | None: - return self.get_external_knowledge_info(session=db.session()) - def get_external_knowledge_info(self, *, session: Session) -> dict[str, Any] | None: if self.provider != "external": return None @@ -974,17 +954,15 @@ class DocumentSegment(TypeBase): """Load the owning document with the caller-owned database session.""" return session.get(Document, self.document_id) - @property - def previous_segment(self): - return db.session.scalar( + def previous_segment(self, session: Session) -> "DocumentSegment | None": + return session.scalar( select(DocumentSegment).where( DocumentSegment.document_id == self.document_id, DocumentSegment.position == self.position - 1 ) ) - @property - def next_segment(self): - return db.session.scalar( + def next_segment(self, session: Session) -> "DocumentSegment | None": + return session.scalar( select(DocumentSegment).where( DocumentSegment.document_id == self.document_id, DocumentSegment.position == self.position + 1 ) @@ -1204,9 +1182,8 @@ class AppDatasetJoin(TypeBase): DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False ) - @property - def app(self): - return db.session.get(App, self.app_id) + def app(self, session: Session) -> App | None: + return session.get(App, self.app_id) class DatasetQuery(TypeBase): diff --git a/api/pyproject.toml b/api/pyproject.toml index dd67187413a..de89fdd5676 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -217,7 +217,7 @@ storage = [ ############################################################ # [ Tools ] dependency group ############################################################ -tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"] +tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.3,<4.0.0"] ############################################################ # [ VDB ] workspace plugins — hollow packages under providers/vdb/* diff --git a/api/repositories/web_passport_repository.py b/api/repositories/web_passport_repository.py new file mode 100644 index 00000000000..f312c92d007 --- /dev/null +++ b/api/repositories/web_passport_repository.py @@ -0,0 +1,150 @@ +"""SQLAlchemy persistence adapter for web passport issuance.""" + +from collections.abc import Callable + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, sessionmaker + +from models.enums import AppStatus, EndUserType +from models.model import App, EndUser, Site +from services.entities.passport_entities import EndUserRecord, WebAppRecord, WebPassportEndUserResolution + + +class WebPassportRepository: + def __init__( + self, + *, + session_factory: sessionmaker[Session], + generate_session_id: Callable[[], str], + ) -> None: + self._session_factory = session_factory + self._generate_session_id = generate_session_id + + def get_active_web_app(self, app_code: str) -> WebAppRecord | None: + stmt = self._active_web_app_stmt(app_code).limit(1) + with self._session_factory() as session: + row = session.execute(stmt).one_or_none() + if row is None: + return None + site_id, app_id, tenant_id, persisted_app_code = row + return WebAppRecord( + site_id=str(site_id), + app_id=str(app_id), + tenant_id=str(tenant_id), + app_code=str(persisted_app_code), + ) + + def is_web_app_active(self, app: WebAppRecord) -> bool: + with self._session_factory() as session: + return self._is_web_app_active(session, app) + + def resolve_standard_end_user( + self, + app: WebAppRecord, + session_id: str | None, + ) -> WebPassportEndUserResolution: + with self._session_factory.begin() as session: + if not self._is_web_app_active(session, app): + return WebPassportEndUserResolution(app_active=False, end_user=None) + + if session_id: + end_user = self._find_end_user_by_session_id(session, app, session_id) + if end_user is not None: + return WebPassportEndUserResolution(app_active=True, end_user=end_user) + else: + session_id = self._generate_unique_session_id(session) + + end_user = self._create_anonymous_end_user(session, app, session_id) + return WebPassportEndUserResolution(app_active=True, end_user=end_user) + + def resolve_authenticated_end_user( + self, + app: WebAppRecord, + *, + end_user_id: str | None, + session_id: str | None, + ) -> WebPassportEndUserResolution: + with self._session_factory.begin() as session: + if not self._is_web_app_active(session, app): + return WebPassportEndUserResolution(app_active=False, end_user=None) + + end_user = None + if session_id: + end_user = self._find_end_user_by_session_id(session, app, session_id) + if end_user is None: + end_user = self._create_anonymous_end_user(session, app, session_id) + elif end_user_id: + end_user = self._find_end_user_by_id(session, app, end_user_id) + + return WebPassportEndUserResolution(app_active=True, end_user=end_user) + + @staticmethod + def _active_web_app_stmt(app_code: str): + return ( + select(Site.id, App.id, App.tenant_id, Site.code) + .join(App, App.id == Site.app_id) + .where( + Site.code == app_code, + Site.status == AppStatus.NORMAL, + App.status == AppStatus.NORMAL, + App.enable_site.is_(True), + ) + ) + + def _is_web_app_active(self, session: Session, app: WebAppRecord) -> bool: + stmt = self._active_web_app_stmt(app.app_code).where( + Site.id == app.site_id, + App.id == app.app_id, + App.tenant_id == app.tenant_id, + ) + return session.execute(stmt.limit(1)).one_or_none() is not None + + @staticmethod + def _find_end_user_by_id(session: Session, app: WebAppRecord, end_user_id: str) -> EndUserRecord | None: + persisted_id = session.scalar( + select(EndUser.id).where( + EndUser.id == end_user_id, + EndUser.tenant_id == app.tenant_id, + EndUser.app_id == app.app_id, + ) + ) + return EndUserRecord(id=persisted_id) if persisted_id is not None else None + + @staticmethod + def _find_end_user_by_session_id( + session: Session, + app: WebAppRecord, + session_id: str, + ) -> EndUserRecord | None: + end_user_id = session.scalar( + select(EndUser.id).where( + EndUser.session_id == session_id, + EndUser.tenant_id == app.tenant_id, + EndUser.app_id == app.app_id, + ) + ) + return EndUserRecord(id=end_user_id) if end_user_id is not None else None + + @staticmethod + def _create_anonymous_end_user( + session: Session, + app: WebAppRecord, + session_id: str, + ) -> EndUserRecord: + end_user = EndUser( + tenant_id=app.tenant_id, + app_id=app.app_id, + type=EndUserType.BROWSER, + is_anonymous=True, + session_id=session_id, + ) + session.add(end_user) + session.flush() + return EndUserRecord(id=end_user.id) + + def _generate_unique_session_id(self, session: Session) -> str: + while True: + session_id = self._generate_session_id() + stmt = select(func.count()).select_from(EndUser).where(EndUser.session_id == session_id) + if not session.scalar(stmt): + return session_id diff --git a/api/services/account_service.py b/api/services/account_service.py index 9c1aac9e861..565d739f30f 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -79,8 +79,8 @@ from services.errors.account import ( SeatsLimitExceededError, ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError -from services.feature_service import FeatureService from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService +from services.system_feature_service import SystemFeatureService from services.telemetry_service import CommunityTelemetryService from tasks.mail_change_mail_task import ( send_change_mail_completed_notification_task, @@ -446,7 +446,7 @@ class AccountService: session: Session, ) -> Account: """Create an account, preferring explicit user timezone over language-derived defaults.""" - if not FeatureService.get_system_features().is_allow_register and not is_setup: + if not SystemFeatureService.is_registration_allowed() and not is_setup: from controllers.console.error import AccountNotFound raise AccountNotFound() @@ -458,7 +458,7 @@ class AccountService: # account into another workspace does not pass through here and costs no seat. # get_license() carries the full license payload that server-side enforcement needs; # the public system-features endpoint exposes only license status. - if not FeatureService.get_license().seats.is_available(): + if not SystemFeatureService.get_license().seats.is_available(): raise SeatsLimitExceededError("licensed seats limit exceeded") if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email): @@ -1139,7 +1139,7 @@ class TenantService: session: Session, ) -> Tenant: """Create tenant""" - if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard: + if not SystemFeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard: from controllers.console.error import NotAllowedCreateWorkspace raise NotAllowedCreateWorkspace() @@ -1202,10 +1202,10 @@ class TenantService: owner. It persists the legacy membership before creating the matching RBAC role binding, then makes the workspace current for the account. """ - if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard: + if not SystemFeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard: raise WorkSpaceNotAllowedCreateError() - workspaces = FeatureService.get_license().workspaces + workspaces = SystemFeatureService.get_license().workspaces if not workspaces.is_available(): raise WorkspacesLimitExceededError() @@ -1988,9 +1988,9 @@ class RegisterService: AccountService.link_account_integrate(provider, open_id, account, session=session) if ( - FeatureService.is_workspace_creation_allowed() + SystemFeatureService.is_workspace_creation_allowed() and create_workspace_required - and FeatureService.get_license().workspaces.is_available() + and SystemFeatureService.get_license().workspaces.is_available() ): try: TenantService.create_owner_tenant(account, session=session) diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index ccb79c65dca..a45ab42bf28 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -44,7 +44,7 @@ from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentW from services.app_service import AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService from tasks.collect_agent_resources_task import enqueue_agent_resource_collection logger = logging.getLogger(__name__) @@ -1120,7 +1120,7 @@ class AgentRosterService: source_include_draft=not source_agent.active_config_is_published, ) self._session.commit() - if FeatureService.get_system_features().webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): try: original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id) access_mode = original_settings.access_mode diff --git a/api/services/app_service.py b/api/services/app_service.py index 89b622463b7..30797c97ede 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -49,8 +49,8 @@ from services.agent.workspace_service import AgentWorkspaceService from services.billing_service import BillingService from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService -from services.feature_service import FeatureService from services.openapi.visibility import apply_openapi_gate, is_openapi_visible +from services.system_feature_service import SystemFeatureService from services.tag_service import TagService from tasks.collect_agent_resources_task import enqueue_agent_resource_collection from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task @@ -703,7 +703,7 @@ class AppService: app.id, ) - if FeatureService.get_system_features().webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): # update web app setting as private EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private") @@ -1155,7 +1155,7 @@ class AppService: ) # clean up web app settings - if FeatureService.get_system_features().webapp_auth.enabled: + if SystemFeatureService.is_webapp_auth_enabled(): EnterpriseService.WebAppAuth.cleanup_webapp(app.id) if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 336b97d74f2..718d1650af0 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -478,7 +478,6 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ _LEGACY_APP_OWNER_KEYS: list[str] = [ "app.acl.preview", - "app.acl.access_point_manage", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", @@ -494,7 +493,6 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [ _LEGACY_APP_ADMIN_KEYS: list[str] = [ "app.acl.preview", "app.acl.view_layout", - "app.acl.access_point_manage", "app.acl.test_and_run", "app.acl.edit", "app.acl.import_export_dsl", @@ -509,7 +507,6 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [ _LEGACY_APP_EDITOR_KEYS: list[str] = [ "app.acl.preview", - "app.acl.access_point_manage", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", diff --git a/api/services/entities/mail_entities.py b/api/services/entities/mail_entities.py new file mode 100644 index 00000000000..221cb00a812 --- /dev/null +++ b/api/services/entities/mail_entities.py @@ -0,0 +1,12 @@ +"""Framework-neutral data contracts for internal mail delivery.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class InnerMailMessage: + recipients: tuple[str, ...] + subject: str + body: str + substitutions: dict[str, Any] | None = None diff --git a/api/services/entities/passport_entities.py b/api/services/entities/passport_entities.py new file mode 100644 index 00000000000..47186464f0c --- /dev/null +++ b/api/services/entities/passport_entities.py @@ -0,0 +1,47 @@ +"""Framework-neutral data contracts for web passport issuance.""" + +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict + + +@dataclass(frozen=True, slots=True) +class WebAppRecord: + site_id: str + app_id: str + tenant_id: str + app_code: str + + +@dataclass(frozen=True, slots=True) +class EndUserRecord: + id: str + + +@dataclass(frozen=True, slots=True) +class WebPassportEndUserResolution: + app_active: bool + end_user: EndUserRecord | None + + +@dataclass(frozen=True, slots=True) +class WebPassportRequest: + app_code: str + user_session_id: str | None + access_token: str | None + + +@dataclass(frozen=True, slots=True) +class WebPassportResult: + access_token: str + + +class WebAppLoginClaims(BaseModel): + token_source: str | None = None + user_id: str | None = None + end_user_id: str | None = None + session_id: str | None = None + auth_type: str | None = None + exp: int | None = None + + model_config = ConfigDict(extra="ignore") diff --git a/api/services/feature_query_service.py b/api/services/feature_query_service.py index 8e0c82e6e25..b3a72ab9ea8 100644 --- a/api/services/feature_query_service.py +++ b/api/services/feature_query_service.py @@ -36,10 +36,16 @@ class FeatureQueryService: self._app_dsl_version = app_dsl_version def get_features(self, context: RequestContext) -> FeatureModel: - return self._features.get_workspace_features(self._require_active_workspace(context)) + return self.get_workspace_features(self._require_active_workspace(context)) + + def get_workspace_features(self, workspace_id: str) -> FeatureModel: + return self._features.get_workspace_features(workspace_id) def get_vector_space(self, context: RequestContext) -> VectorSpaceLimitationModel: - return self._features.get_vector_space(self._require_active_workspace(context)) + return self.get_workspace_vector_space(self._require_active_workspace(context)) + + def get_workspace_vector_space(self, workspace_id: str) -> VectorSpaceLimitationModel: + return self._features.get_vector_space(workspace_id) def get_trial_models(self, context: RequestContext) -> list[str]: return self._features.get_trial_models(self._require_active_workspace(context)) @@ -47,7 +53,7 @@ class FeatureQueryService: def get_app_dsl_version(self) -> str: return self._app_dsl_version - def get_system_features(self) -> SystemFeatureModel: + def get_public_system_features(self) -> SystemFeatureModel: return self._features.get_public_system_features() def get_license(self) -> LicenseModel: diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 676ecf9832a..b7627e9df4a 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -1,23 +1,9 @@ -import logging -from collections.abc import Mapping - -from pydantic import BaseModel, ConfigDict, Field, ValidationError - from configs import dify_config from enums import CloudPlan, DeploymentEdition, HostedTrialProvider from services.billing_service import BillingInfo, BillingService from services.enterprise.enterprise_service import EnterpriseService from services.entities import feature_entities -logger = logging.getLogger(__name__) - - -class _EnterprisePluginInstallationPermission(BaseModel): - model_config = ConfigDict(extra="ignore") - - plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope") - restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True) - class FeatureService: @classmethod @@ -102,104 +88,6 @@ class FeatureService: return False return features.billing.enabled and features.billing.subscription.plan.is_paid - @classmethod - def get_system_features(cls) -> feature_entities.SystemFeatureModel: - system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION) - system_features.rbac_enabled = dify_config.RBAC_ENABLED - - cls._fulfill_system_params_from_env(system_features) - system_features.webapp_auth.enabled = cls.is_webapp_auth_enabled() - - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: - system_features.branding.enabled = True - system_features.enable_change_email = False - cls._fulfill_params_from_enterprise(system_features) - - if dify_config.MARKETPLACE_ENABLED: - system_features.enable_marketplace = True - - if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: - system_features.enable_creators_platform = True - - return system_features - - @classmethod - def is_workspace_creation_allowed(cls) -> bool: - """Resolve the backend workspace-creation policy, including the Enterprise override.""" - is_allowed = dify_config.ALLOW_CREATE_WORKSPACE - if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: - return is_allowed - - enterprise_info = EnterpriseService.get_info() - return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed)) - - @classmethod - def is_plugin_manager_enabled(cls) -> bool: - """Return whether Enterprise plugin credential policies must be enforced.""" - return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE - - @classmethod - def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel: - """Resolve the validated deployment-wide plugin installation policy.""" - if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: - return feature_entities.PluginInstallationPermissionModel() - - return cls._resolve_plugin_installation_permission(EnterpriseService.get_info()) - - @classmethod - def get_license(cls) -> feature_entities.LicenseModel: - """Return full license detail. Enterprise-only; requires an authenticated caller. - - Non-enterprise deployments have no license, so an unconstrained default - (unlimited seats/workspaces) is returned. - """ - if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: - return feature_entities.LicenseModel() - license_model = cls._build_license(EnterpriseService.get_info()) - license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE - return license_model - - @staticmethod - def is_explore_banner_enabled() -> bool: - return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER - - @staticmethod - def is_webapp_auth_enabled() -> bool: - return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE - - @staticmethod - def is_trial_app_enabled() -> bool: - return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP - - @classmethod - def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel): - system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN - system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN - system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN - system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE - system_features.is_allow_register = dify_config.ALLOW_REGISTER - system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" - system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL - system_features.enable_explore_banner = cls.is_explore_banner_enabled() - system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP - system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED - system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR - knowledge_fs_enabled = bool( - dify_config.KNOWLEDGE_FS_ENABLED - and ( - dify_config.DEPLOYMENT_EDITION in {DeploymentEdition.CLOUD, DeploymentEdition.ENTERPRISE} - or dify_config.KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED - ) - ) - system_features.knowledge_fs_enabled = knowledge_fs_enabled - system_features.knowledge_fs_upload_enabled = bool( - knowledge_fs_enabled - and dify_config.KNOWLEDGE_FS_BASE_URL - and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED - and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID - and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM - ) - @classmethod def _fulfill_trial_models_from_env(cls, quota_types: tuple[str, ...] | None = None) -> list[str]: allowed_quota_types = quota_types or ("PAID", "TRIAL") @@ -325,125 +213,3 @@ class FeatureService: # but feature API keeps LimitationModel.size as int for compatibility. vector_space.size = int(billing_info["vector_space"]["size"]) vector_space.limit = billing_info["vector_space"]["limit"] - - @classmethod - def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel: - license_model = feature_entities.LicenseModel() - if license_info := enterprise_info.get("License"): - license_model.status = feature_entities.LicenseStatus( - license_info.get("status", feature_entities.LicenseStatus.INACTIVE) - ) - license_model.expired_at = license_info.get("expiredAt", "") - if workspaces_info := license_info.get("workspaces"): - license_model.workspaces = feature_entities.LicenseLimitationModel( - enabled=workspaces_info.get("enabled", False), - limit=workspaces_info.get("limit", 0), - size=workspaces_info.get("used", 0), - ) - if seats_info := license_info.get("licensedSeats"): - license_model.seats = feature_entities.LicenseLimitationModel( - enabled=seats_info.get("enabled", False), - limit=seats_info.get("limit", 0), - size=seats_info.get("used", 0), - ) - return license_model - - @classmethod - def _resolve_plugin_installation_permission( - cls, enterprise_info: Mapping[str, object] - ) -> feature_entities.PluginInstallationPermissionModel: - if "PluginInstallationPermission" not in enterprise_info: - return feature_entities.PluginInstallationPermissionModel() - - try: - permission = _EnterprisePluginInstallationPermission.model_validate( - enterprise_info["PluginInstallationPermission"] - ) - except ValidationError as exc: - # Do not attach the exception because it may contain raw Enterprise configuration values. - logger.error( # noqa: TRY400 - "Invalid Enterprise plugin installation permission; denying all plugin installations: %s", - exc.errors(include_input=False), - ) - return feature_entities.PluginInstallationPermissionModel( - plugin_installation_scope=feature_entities.PluginInstallationScope.NONE, - restrict_to_marketplace_only=True, - ) - - return feature_entities.PluginInstallationPermissionModel( - plugin_installation_scope=permission.plugin_installation_scope, - restrict_to_marketplace_only=permission.restrict_to_marketplace_only, - ) - - @staticmethod - def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None: - if value is None or (isinstance(value, str) and not value.strip()): - return None - - if not isinstance(value, str): - logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name) - return None - - try: - return feature_entities.SSOProtocol(value) - except ValueError: - logger.error( # noqa: TRY400 - "Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name - ) - return None - - @classmethod - def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel): - enterprise_info = EnterpriseService.get_info() - - if "SSOEnforcedForSignin" in enterprise_info: - features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"] - - features.sso_enforced_for_signin_protocol = cls._resolve_sso_protocol( - enterprise_info.get("SSOEnforcedForSigninProtocol"), - field_name="SSOEnforcedForSigninProtocol", - ) - - if "EnableEmailCodeLogin" in enterprise_info: - features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"] - - if "EnableEmailPasswordLogin" in enterprise_info: - features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"] - - if "IsAllowRegister" in enterprise_info: - features.is_allow_register = enterprise_info["IsAllowRegister"] - - if "EnableAppDeploy" in enterprise_info: - features.enable_app_deploy = enterprise_info["EnableAppDeploy"] - - if "Branding" in enterprise_info: - features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "") - features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "") - features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "") - features.branding.favicon = enterprise_info["Branding"].get("favicon", "") - - if "WebAppAuth" in enterprise_info: - features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False) - features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get( - "allowEmailCodeLogin", False - ) - features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get( - "allowEmailPasswordLogin", False - ) - features.webapp_auth.sso_config.protocol = cls._resolve_sso_protocol( - enterprise_info.get("SSOEnforcedForWebProtocol"), - field_name="SSOEnforcedForWebProtocol", - ) - - # SECURITY NOTE: system-features is unauthenticated, so it exposes only license - # *status* — enough for the login page to detect an expired/inactive license after - # force-logout. Full license detail (expiry, workspace/seat usage) is served - # separately by get_license() behind an authenticated endpoint. - if license_info := enterprise_info.get("License"): - features.license = feature_entities.LicenseStatusModel( - status=feature_entities.LicenseStatus( - license_info.get("status", feature_entities.LicenseStatus.INACTIVE) - ) - ) - - features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info) diff --git a/api/services/feature_service_gateway.py b/api/services/feature_service_gateway.py index 1a93b380dd5..4ecd9593817 100644 --- a/api/services/feature_service_gateway.py +++ b/api/services/feature_service_gateway.py @@ -1,4 +1,4 @@ -"""Feature-query gateway backed by the existing FeatureService.""" +"""Feature-query gateway combining workspace and deployment feature providers.""" from typing import override @@ -10,10 +10,11 @@ from services.entities.feature_entities import ( ) from services.feature_query_service import FeatureQueryGateway from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class FeatureServiceGateway(FeatureQueryGateway): - """Read dynamic feature resources through FeatureService.""" + """Read workspace features from FeatureService and deployment features from SystemFeatureService.""" @override def get_workspace_features(self, workspace_id: str) -> FeatureModel: @@ -29,8 +30,8 @@ class FeatureServiceGateway(FeatureQueryGateway): @override def get_public_system_features(self) -> SystemFeatureModel: - return FeatureService.get_system_features() + return SystemFeatureService.get_public_system_features() @override def get_license(self) -> LicenseModel: - return FeatureService.get_license() + return SystemFeatureService.get_license() diff --git a/api/services/inner_mail_service.py b/api/services/inner_mail_service.py new file mode 100644 index 00000000000..a59236daeb8 --- /dev/null +++ b/api/services/inner_mail_service.py @@ -0,0 +1,17 @@ +"""Application service for mail received through the inner API.""" + +from typing import Protocol + +from services.entities.mail_entities import InnerMailMessage + + +class InnerMailDispatcher(Protocol): + def __call__(self, message: InnerMailMessage) -> None: ... + + +class InnerMailService: + def __init__(self, *, dispatch: InnerMailDispatcher) -> None: + self._dispatch = dispatch + + def send(self, message: InnerMailMessage) -> None: + self._dispatch(message) diff --git a/api/services/installed_app_service.py b/api/services/installed_app_service.py index e3ff637190e..ba879e4bfbf 100644 --- a/api/services/installed_app_service.py +++ b/api/services/installed_app_service.py @@ -8,7 +8,7 @@ from libs.helper import escape_like_pattern from models import App, AppModelConfig, InstalledApp, Workflow from models.model import AppMode from services.enterprise.enterprise_service import EnterpriseService -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class InstalledAppCursor(BaseModel): @@ -118,7 +118,7 @@ class InstalledAppService: escaped_name = escape_like_pattern(normalized_name) stmt = stmt.where(App.name.ilike(f"%{escaped_name}%", escape="\\")) - webapp_auth_enabled = FeatureService.get_system_features().webapp_auth.enabled + webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled() scan_size = limit * 2 if webapp_auth_enabled else limit + 1 visible_rows: list[tuple[InstalledApp, App]] = [] scan_cursor = cursor diff --git a/api/services/openapi/license_gate.py b/api/services/openapi/license_gate.py index 7ca7779de6f..8df92eb344b 100644 --- a/api/services/openapi/license_gate.py +++ b/api/services/openapi/license_gate.py @@ -5,8 +5,7 @@ the EE blueprint chain is what gives CE deploys no callers on this surface in practice, but the explicit short-circuit avoids any test/fixture that flips the surface on without flipping the license. -Reuses ``FeatureService.get_system_features()`` so the license status -travels the same path as the console reads. +Uses the narrow system license policy shared with Console admission. Companion to ``controllers.console.wraps.enterprise_license_required`` — that one is for console (cookie-authed, force-logout 401). This one is @@ -24,7 +23,7 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from enums import DeploymentEdition from services.entities.feature_entities import LicenseStatus -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService logger = logging.getLogger(__name__) @@ -47,8 +46,8 @@ def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: def _is_license_valid() -> bool: try: - features = FeatureService.get_system_features() + license_status = SystemFeatureService.get_license_status() except Exception: - logger.exception("license_gate: FeatureService.get_system_features failed") + logger.exception("license_gate: SystemFeatureService.get_license_status failed") return False - return features.license.status in _VALID_LICENSE_STATUSES + return license_status in _VALID_LICENSE_STATUSES diff --git a/api/services/system_feature_service.py b/api/services/system_feature_service.py new file mode 100644 index 00000000000..66d30c9ca2b --- /dev/null +++ b/api/services/system_feature_service.py @@ -0,0 +1,301 @@ +"""Deployment-wide feature policies and the public system-features snapshot.""" + +import logging +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from configs import dify_config +from enums import DeploymentEdition +from services.enterprise.enterprise_service import EnterpriseService +from services.entities import feature_entities + +logger = logging.getLogger(__name__) + + +class _EnterprisePluginInstallationPermission(BaseModel): + model_config = ConfigDict(extra="ignore") + + plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope") + restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True) + + +class SystemFeatureService: + """Resolve deployment-wide policies without exposing the public response DTO internally.""" + + @classmethod + def get_public_system_features(cls) -> feature_entities.SystemFeatureModel: + """Build the non-sensitive bootstrap snapshot shared by Console and Web.""" + system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION) + system_features.rbac_enabled = dify_config.RBAC_ENABLED + + cls._fulfill_system_params_from_env(system_features) + + if cls.is_webapp_auth_enabled(): + system_features.branding.enabled = True + system_features.webapp_auth.enabled = True + system_features.enable_change_email = False + cls._fulfill_params_from_enterprise(system_features) + + if dify_config.MARKETPLACE_ENABLED: + system_features.enable_marketplace = True + + if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: + system_features.enable_creators_platform = True + + return system_features + + @classmethod + def is_registration_allowed(cls) -> bool: + """Return the effective registration policy, including the Enterprise override.""" + is_allowed = dify_config.ALLOW_REGISTER + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return is_allowed + + enterprise_info = EnterpriseService.get_info() + return bool(enterprise_info.get("IsAllowRegister", is_allowed)) + + @classmethod + def is_email_password_login_enabled(cls) -> bool: + """Return the effective password-login policy, including the Enterprise override.""" + is_enabled = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return is_enabled + + enterprise_info = EnterpriseService.get_info() + return bool(enterprise_info.get("EnableEmailPasswordLogin", is_enabled)) + + @staticmethod + def is_change_email_enabled() -> bool: + """Return whether Console accounts may change their email address.""" + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: + return False + return dify_config.ENABLE_CHANGE_EMAIL + + @staticmethod + def is_webapp_auth_enabled(*, deployment_edition: DeploymentEdition | None = None) -> bool: + """Return whether deployment-level WebApp authentication integration is enabled.""" + edition = deployment_edition if deployment_edition is not None else dify_config.DEPLOYMENT_EDITION + return edition == DeploymentEdition.ENTERPRISE + + @classmethod + def get_license_status(cls) -> feature_entities.LicenseStatus: + """Return the deployment license status used by internal admission policies.""" + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return feature_entities.LicenseStatus.NONE + return cls._resolve_license_status(EnterpriseService.get_info()) + + @classmethod + def get_branding(cls) -> feature_entities.BrandingModel: + """Return the deployment branding used by server-rendered email.""" + branding = feature_entities.BrandingModel(enabled=cls.is_webapp_auth_enabled()) + if not branding.enabled: + return branding + + enterprise_info = EnterpriseService.get_info() + if branding_info := enterprise_info.get("Branding"): + branding.application_title = branding_info.get("applicationTitle", "") + branding.login_page_logo = branding_info.get("loginPageLogo", "") + branding.workspace_logo = branding_info.get("workspaceLogo", "") + branding.favicon = branding_info.get("favicon", "") + return branding + + @classmethod + def is_workspace_creation_allowed(cls) -> bool: + """Resolve the backend workspace-creation policy, including the Enterprise override.""" + is_allowed = dify_config.ALLOW_CREATE_WORKSPACE + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return is_allowed + + enterprise_info = EnterpriseService.get_info() + return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed)) + + @staticmethod + def is_plugin_manager_enabled() -> bool: + """Return whether Enterprise plugin credential policies must be enforced.""" + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE + + @classmethod + def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel: + """Resolve the validated deployment-wide plugin installation policy.""" + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return feature_entities.PluginInstallationPermissionModel() + + return cls._resolve_plugin_installation_permission(EnterpriseService.get_info()) + + @classmethod + def get_license(cls) -> feature_entities.LicenseModel: + """Return full license detail for authenticated server-side consumers.""" + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return feature_entities.LicenseModel() + license_model = cls._build_license(EnterpriseService.get_info()) + license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE + return license_model + + @staticmethod + def is_explore_banner_enabled() -> bool: + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER + + @staticmethod + def is_trial_app_enabled() -> bool: + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP + + @classmethod + def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel) -> None: + system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN + system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN + system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN + system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE + system_features.is_allow_register = dify_config.ALLOW_REGISTER + system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" + system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL + system_features.enable_explore_banner = cls.is_explore_banner_enabled() + system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP + system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED + system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR + knowledge_fs_enabled = bool( + dify_config.KNOWLEDGE_FS_ENABLED + and ( + dify_config.DEPLOYMENT_EDITION in {DeploymentEdition.CLOUD, DeploymentEdition.ENTERPRISE} + or dify_config.KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED + ) + ) + system_features.knowledge_fs_enabled = knowledge_fs_enabled + system_features.knowledge_fs_upload_enabled = bool( + knowledge_fs_enabled + and dify_config.KNOWLEDGE_FS_BASE_URL + and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED + and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID + and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM + ) + + @classmethod + def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel) -> None: + enterprise_info = EnterpriseService.get_info() + + if "SSOEnforcedForSignin" in enterprise_info: + features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"] + + features.sso_enforced_for_signin_protocol = cls._resolve_sso_protocol( + enterprise_info.get("SSOEnforcedForSigninProtocol"), + field_name="SSOEnforcedForSigninProtocol", + ) + + if "EnableEmailCodeLogin" in enterprise_info: + features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"] + + if "EnableEmailPasswordLogin" in enterprise_info: + features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"] + + if "IsAllowRegister" in enterprise_info: + features.is_allow_register = enterprise_info["IsAllowRegister"] + + if "EnableAppDeploy" in enterprise_info: + features.enable_app_deploy = enterprise_info["EnableAppDeploy"] + + if "Branding" in enterprise_info: + features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "") + features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "") + features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "") + features.branding.favicon = enterprise_info["Branding"].get("favicon", "") + + if "WebAppAuth" in enterprise_info: + features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False) + features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get( + "allowEmailCodeLogin", False + ) + features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get( + "allowEmailPasswordLogin", False + ) + features.webapp_auth.sso_config.protocol = cls._resolve_sso_protocol( + enterprise_info.get("SSOEnforcedForWebProtocol"), + field_name="SSOEnforcedForWebProtocol", + ) + + # The unauthenticated endpoint exposes status only. Full license detail is + # served by the authenticated license endpoint. + license_status = cls._resolve_license_status(enterprise_info) + if license_status != feature_entities.LicenseStatus.NONE: + features.license = feature_entities.LicenseStatusModel( + status=license_status, + ) + + features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info) + + @staticmethod + def _resolve_license_status(enterprise_info: Mapping[str, object]) -> feature_entities.LicenseStatus: + license_info = enterprise_info.get("License") + if not license_info: + return feature_entities.LicenseStatus.NONE + if not isinstance(license_info, Mapping): + return feature_entities.LicenseStatus.INACTIVE + + status = license_info.get("status", feature_entities.LicenseStatus.INACTIVE) + if isinstance(status, feature_entities.LicenseStatus): + return status + if isinstance(status, str): + return feature_entities.LicenseStatus(status) + return feature_entities.LicenseStatus.INACTIVE + + @classmethod + def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel: + license_model = feature_entities.LicenseModel() + if license_info := enterprise_info.get("License"): + license_model.status = feature_entities.LicenseStatus( + license_info.get("status", feature_entities.LicenseStatus.INACTIVE) + ) + license_model.expired_at = license_info.get("expiredAt", "") + if workspaces_info := license_info.get("workspaces"): + license_model.workspaces = feature_entities.LicenseLimitationModel( + enabled=workspaces_info.get("enabled", False), + limit=workspaces_info.get("limit", 0), + size=workspaces_info.get("used", 0), + ) + if seats_info := license_info.get("licensedSeats"): + license_model.seats = feature_entities.LicenseLimitationModel( + enabled=seats_info.get("enabled", False), + limit=seats_info.get("limit", 0), + size=seats_info.get("used", 0), + ) + return license_model + + @classmethod + def _resolve_plugin_installation_permission( + cls, enterprise_info: Mapping[str, object] + ) -> feature_entities.PluginInstallationPermissionModel: + if "PluginInstallationPermission" not in enterprise_info: + return feature_entities.PluginInstallationPermissionModel() + + try: + permission = _EnterprisePluginInstallationPermission.model_validate( + enterprise_info["PluginInstallationPermission"] + ) + except ValidationError as exc: + logger.error( # noqa: TRY400 + "Invalid Enterprise plugin installation permission; denying all plugin installations: %s", + exc.errors(include_input=False), + ) + return feature_entities.PluginInstallationPermissionModel( + plugin_installation_scope=feature_entities.PluginInstallationScope.NONE, + restrict_to_marketplace_only=True, + ) + + return feature_entities.PluginInstallationPermissionModel( + plugin_installation_scope=permission.plugin_installation_scope, + restrict_to_marketplace_only=permission.restrict_to_marketplace_only, + ) + + @staticmethod + def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None: + if value is None or (isinstance(value, str) and not value.strip()): + return None + + if not isinstance(value, str): + logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name) + return None + + try: + return feature_entities.SSOProtocol(value) + except ValueError: + logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name) # noqa: TRY400 + return None diff --git a/api/services/web_passport_gateways.py b/api/services/web_passport_gateways.py new file mode 100644 index 00000000000..f6cf423302a --- /dev/null +++ b/api/services/web_passport_gateways.py @@ -0,0 +1,49 @@ +"""Outer gateways used by the web passport application service.""" + +from collections.abc import Callable, Mapping +from typing import Any + +from werkzeug.exceptions import Unauthorized + +from libs.passport import PassportService +from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, WebAppAccessMode, WebAppSettings +from services.web_passport_service import WebAppAuthType, WebPassportUnauthorizedError + + +class DeploymentWebPassportAuthGateway: + def __init__( + self, + *, + webapp_auth_enabled: bool, + get_app_access_mode: Callable[[str], WebAppSettings], + ) -> None: + self._webapp_auth_enabled = webapp_auth_enabled + self._get_app_access_mode = get_app_access_mode + + def is_webapp_auth_enabled(self) -> bool: + return self._webapp_auth_enabled + + def get_app_auth_type(self, app_id: str) -> WebAppAuthType: + access_mode = self._get_app_access_mode(app_id).access_mode + if access_mode == WebAppAccessMode.PUBLIC: + return WebAppAuthType.PUBLIC + if access_mode in PERMISSION_CHECK_MODES: + return WebAppAuthType.INTERNAL + if access_mode == WebAppAccessMode.SSO_VERIFIED: + return WebAppAuthType.EXTERNAL + raise ValueError(f"Unsupported web app access mode: {access_mode}") + + +class PassportTokenGateway: + def __init__(self, *, passport: PassportService) -> None: + self._passport = passport + + def verify(self, token: str) -> Mapping[str, Any]: + try: + return self._passport.verify(token) + except Unauthorized as exc: + description = exc.description or "Invalid token." + raise WebPassportUnauthorizedError(description) from exc + + def issue(self, payload: Mapping[str, Any]) -> str: + return self._passport.issue(dict(payload)) diff --git a/api/services/web_passport_service.py b/api/services/web_passport_service.py new file mode 100644 index 00000000000..65240368ce6 --- /dev/null +++ b/api/services/web_passport_service.py @@ -0,0 +1,185 @@ +"""Application service for issuing passports used by deployed web applications.""" + +from collections.abc import Callable, Mapping +from datetime import datetime, timedelta +from enum import StrEnum +from typing import Any, Protocol + +from pydantic import ValidationError + +from services.entities.passport_entities import ( + EndUserRecord, + WebAppLoginClaims, + WebAppRecord, + WebPassportEndUserResolution, + WebPassportRequest, + WebPassportResult, +) + + +class WebAppAuthType(StrEnum): + PUBLIC = "public" + INTERNAL = "internal" + EXTERNAL = "external" + + +class WebPassportNotFoundError(Exception): + pass + + +class WebPassportUnauthorizedError(Exception): + pass + + +class WebPassportAuthenticationRequiredError(Exception): + pass + + +class WebPassportRepository(Protocol): + def get_active_web_app(self, app_code: str) -> WebAppRecord | None: ... + + def is_web_app_active(self, app: WebAppRecord) -> bool: ... + + def resolve_standard_end_user(self, app: WebAppRecord, session_id: str | None) -> WebPassportEndUserResolution: ... + + def resolve_authenticated_end_user( + self, + app: WebAppRecord, + *, + end_user_id: str | None, + session_id: str | None, + ) -> WebPassportEndUserResolution: ... + + +class WebPassportAuthGateway(Protocol): + def is_webapp_auth_enabled(self) -> bool: ... + + def get_app_auth_type(self, app_id: str) -> WebAppAuthType: ... + + +class WebPassportTokenGateway(Protocol): + def verify(self, token: str) -> Mapping[str, Any]: ... + + def issue(self, payload: Mapping[str, Any]) -> str: ... + + +class WebPassportService: + def __init__( + self, + *, + passports: WebPassportRepository, + auth: WebPassportAuthGateway, + tokens: WebPassportTokenGateway, + now: Callable[[], datetime], + access_token_expire_minutes: int, + ) -> None: + self._passports = passports + self._auth = auth + self._tokens = tokens + self._now = now + self._access_token_expire_minutes = access_token_expire_minutes + + def issue(self, request: WebPassportRequest) -> WebPassportResult: + app = self._passports.get_active_web_app(request.app_code) + if app is None: + raise WebPassportNotFoundError() + + login_claims: WebAppLoginClaims | None = None + if self._auth.is_webapp_auth_enabled(): + login_claims = self._decode_login_token(request.access_token) + auth_type = self._auth.get_app_auth_type(app.app_id) + if auth_type != WebAppAuthType.PUBLIC: + if login_claims is None: + raise WebPassportAuthenticationRequiredError("Web app authentication required.") + self._require_active_web_app(app) + return self._exchange_enterprise_token(app, login_claims, auth_type) + + end_user = self._resolve_standard_user(app, request.user_session_id) + token = self._tokens.issue( + { + "iss": app.app_id, + "sub": "Web API Passport", + "app_id": app.app_id, + "app_code": app.app_code, + "end_user_id": end_user.id, + } + ) + return WebPassportResult(access_token=token) + + def _decode_login_token(self, token: str | None) -> WebAppLoginClaims | None: + if not token: + return None + + decoded = self._tokens.verify(token) + try: + claims = WebAppLoginClaims.model_validate(decoded) + except ValidationError as exc: + raise WebPassportUnauthorizedError("Invalid web app login token.") from exc + + if claims.token_source != "webapp_login_token": + raise WebPassportUnauthorizedError("Invalid token source. Expected 'webapp_login_token'.") + return claims + + def _resolve_standard_user(self, app: WebAppRecord, session_id: str | None) -> EndUserRecord: + resolution = self._passports.resolve_standard_end_user(app, session_id) + self._require_active_resolution(resolution) + if resolution.end_user is None: + raise WebPassportNotFoundError() + return resolution.end_user + + def _exchange_enterprise_token( + self, + app: WebAppRecord, + claims: WebAppLoginClaims, + auth_type: WebAppAuthType, + ) -> WebPassportResult: + user_auth_type = claims.auth_type + if not user_auth_type: + raise WebPassportUnauthorizedError("Missing auth_type in the token.") + + if auth_type == WebAppAuthType.EXTERNAL and user_auth_type != WebAppAuthType.EXTERNAL: + raise WebPassportAuthenticationRequiredError("Please login as external user.") + if auth_type == WebAppAuthType.INTERNAL and user_auth_type != WebAppAuthType.INTERNAL: + raise WebPassportAuthenticationRequiredError("Please login as internal user.") + + resolution = self._passports.resolve_authenticated_end_user( + app, + end_user_id=claims.end_user_id, + session_id=claims.session_id, + ) + self._require_active_resolution(resolution) + if resolution.end_user is None: + if not claims.session_id: + raise WebPassportNotFoundError("Missing session_id for existing web user.") + raise WebPassportNotFoundError() + end_user = resolution.end_user + + now = self._now() + expires_at = int((now + timedelta(minutes=self._access_token_expire_minutes)).timestamp()) + if claims.exp: + expires_at = int(claims.exp) + + token = self._tokens.issue( + { + "iss": app.site_id, + "sub": "Web API Passport", + "app_id": app.app_id, + "app_code": app.app_code, + "user_id": claims.user_id, + "end_user_id": end_user.id, + "auth_type": user_auth_type, + "granted_at": int(now.timestamp()), + "token_source": "webapp", + "exp": expires_at, + } + ) + return WebPassportResult(access_token=token) + + def _require_active_web_app(self, app: WebAppRecord) -> None: + if not self._passports.is_web_app_active(app): + raise WebPassportNotFoundError() + + @staticmethod + def _require_active_resolution(resolution: WebPassportEndUserResolution) -> None: + if not resolution.app_active: + raise WebPassportNotFoundError() diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index d05f04f7dd4..600c2b5e84f 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -1,4 +1,3 @@ -import enum import secrets from datetime import UTC, datetime, timedelta from typing import Any @@ -16,19 +15,11 @@ from models.enums import EndUserType from models.model import App, EndUser, Site from services.account_service import AccountService from services.app_service import AppService -from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService, WebAppAccessMode +from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError from tasks.mail_email_code_login import send_email_code_login_mail_task -class WebAppAuthType(enum.StrEnum): - """Enum for web app authentication types.""" - - PUBLIC = "public" - INTERNAL = "internal" - EXTERNAL = "external" - - class WebAppAuthService: """Service for web app authentication.""" @@ -156,28 +147,3 @@ class WebAppAuthService: if webapp_settings and webapp_settings.access_mode in PERMISSION_CHECK_MODES: return True return False - - @classmethod - def get_app_auth_type( - cls, app_code: str | None = None, access_mode: str | None = None, *, session: Session - ) -> WebAppAuthType: - """ - Get the authentication type for the app based on its access mode. - """ - if not app_code and not access_mode: - raise ValueError("Either app_code or access_mode must be provided.") - - if access_mode: - if access_mode == WebAppAccessMode.PUBLIC: - return WebAppAuthType.PUBLIC - elif access_mode in PERMISSION_CHECK_MODES: - return WebAppAuthType.INTERNAL - elif access_mode == WebAppAccessMode.SSO_VERIFIED: - return WebAppAuthType.EXTERNAL - - if app_code: - app_id = AppService.get_app_id_by_code(app_code, session=session) - webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id) - return cls.get_app_auth_type(access_mode=webapp_settings.access_mode, session=session) - - raise ValueError("Could not determine app authentication type.") diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 7f103f1d885..3778428d5e6 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -94,6 +94,7 @@ from services.errors.app import ( WorkflowHashNotEqualError, WorkflowNotFoundError, ) +from services.system_feature_service import SystemFeatureService from tasks.new_agent_beta_task import register_new_agent_beta_workflow_publish_after_commit @@ -700,9 +701,7 @@ class WorkflowService: ) # Validate credentials before publishing, for credential policy check - from services.feature_service import FeatureService - - if FeatureService.is_plugin_manager_enabled(): + if SystemFeatureService.is_plugin_manager_enabled(): self._validate_workflow_credentials(draft_workflow, session=session) # validate graph structure diff --git a/api/tasks/mail_inner_task.py b/api/tasks/mail_inner_task.py index d4d1ae67ce4..b556b63da98 100644 --- a/api/tasks/mail_inner_task.py +++ b/api/tasks/mail_inner_task.py @@ -13,6 +13,7 @@ from configs import dify_config from configs.feature import TemplateMode from extensions.ext_mail import mail from libs.email_i18n import get_email_i18n_service +from services.entities.mail_entities import InnerMailMessage logger = logging.getLogger(__name__) @@ -29,7 +30,7 @@ class SandboxedEnvironment(ImmutableSandboxedEnvironment): return super().call(context, obj, *args, **kwargs) -def _render_template_with_strategy(body: str, substitutions: Mapping[str, str]) -> str: +def _render_template_with_strategy(body: str, substitutions: Mapping[str, Any]) -> str: mode = dify_config.MAIL_TEMPLATING_MODE timeout = dify_config.MAIL_TEMPLATING_TIMEOUT if mode == TemplateMode.UNSAFE: @@ -43,7 +44,7 @@ def _render_template_with_strategy(body: str, substitutions: Mapping[str, str]) @shared_task(queue="mail") -def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, str]): +def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, Any]): if not mail.is_inited(): return @@ -60,3 +61,12 @@ def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: logger.info(click.style(f"Send enterprise mail to {to} succeeded: latency: {end_at - start_at}", fg="green")) except Exception: logger.exception("Send enterprise mail to %s failed", to) + + +def enqueue_inner_mail(message: InnerMailMessage) -> None: + send_inner_email_task.delay( + to=list(message.recipients), + subject=message.subject, + body=message.body, + substitutions=message.substitutions or {}, + ) diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_setup.py b/api/tests/test_containers_integration_tests/controllers/console/test_setup.py index 248b662fd0b..cee425469e1 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_setup.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_setup.py @@ -21,11 +21,11 @@ from tests.test_containers_integration_tests.helpers import generate_valid_passw @pytest.fixture def setup_dependencies() -> Iterator[MagicMock]: with ( - patch("services.account_service.FeatureService") as feature_service, + patch("services.account_service.SystemFeatureService") as feature_service, patch("services.account_service.BillingService") as billing_service, patch("services.account_service.CommunityTelemetryService.report_install") as report_install, ): - feature_service.get_system_features.return_value.is_allow_register = True + feature_service.is_registration_allowed.return_value = True feature_service.get_license.return_value.seats.is_available.return_value = True feature_service.get_license.return_value.workspaces.is_available.return_value = True feature_service.is_workspace_creation_allowed.return_value = True diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py index 8681f461975..d06da5bc018 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py @@ -38,8 +38,8 @@ def make_account(db_session_with_containers: Session) -> Callable[..., Account]: def _make(*, with_owner_tenant: bool = True) -> Account: fake = Faker() - with patch("services.account_service.FeatureService") as mock_feature_service: - mock_feature_service.get_system_features.return_value.is_allow_register = True + with patch("services.account_service.SystemFeatureService") as mock_feature_service: + mock_feature_service.is_registration_allowed.return_value = True account = AccountService.create_account( email=fake.email(), name=fake.name(), @@ -60,7 +60,7 @@ def add_tenant_for_account( account: Account, *, session: Session, role: str = "normal", name: str = "Second WS" ) -> Tenant: """Create an additional tenant and join ``account`` to it (real service calls).""" - with patch("services.account_service.FeatureService") as mock_feature_service: + with patch("services.account_service.SystemFeatureService") as mock_feature_service: mock_feature_service.is_workspace_creation_allowed.return_value = True tenant = TenantService.create_tenant(name=name, session=session) TenantService.create_tenant_member(tenant, account, session, role=role) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py index 4d9bfb5ea17..3e4ca88288c 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -51,7 +51,7 @@ def external_deps() -> Generator[dict[str, object], None, None]: patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service, patch("services.app_dsl_service.app_was_created") as mock_app_was_created, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, ): mock_workflow_service.return_value.get_draft_workflow.return_value = None @@ -65,7 +65,7 @@ def external_deps() -> Generator[dict[str, object], None, None]: mock_model_instance.get_default_model_instance.return_value = None mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None @@ -74,8 +74,8 @@ def external_deps() -> Generator[dict[str, object], None, None]: def _app_and_account(db_session: Session, *, mode: str = "chat") -> tuple[App, Account]: fake = Faker() - with patch("services.account_service.FeatureService") as mock_account_feature_service: - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + with patch("services.account_service.SystemFeatureService") as mock_account_feature_service: + mock_account_feature_service.is_registration_allowed.return_value = True account = AccountService.create_account( email=fake.email(), name=fake.name(), diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py index 01e241a9b94..9a143ab3bc3 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py @@ -231,7 +231,7 @@ class TestDecodeJwtToken: @patch("controllers.web.wraps._validate_webapp_token") @patch("controllers.web.wraps.EnterpriseService.WebAppAuth.get_app_access_mode_by_id") @patch("controllers.web.wraps.AppService.get_app_id_by_code") - @patch("controllers.web.wraps.FeatureService.get_system_features") + @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.PassportService") @patch("controllers.web.wraps.extract_webapp_passport") def test_happy_path( @@ -254,7 +254,7 @@ class TestDecodeJwtToken: "app_id": app_model.id, "end_user_id": end_user.id, } - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) + mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": site.code}): result_app, result_user = decode_jwt_token() @@ -262,17 +262,17 @@ class TestDecodeJwtToken: assert result_app.id == app_model.id assert result_user.id == end_user.id - @patch("controllers.web.wraps.FeatureService.get_system_features") + @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.extract_webapp_passport") def test_missing_token_raises_unauthorized(self, mock_extract: MagicMock, mock_features: MagicMock, app) -> None: - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) + mock_features.return_value = False mock_extract.return_value = None with app.test_request_context("/", headers={"X-App-Code": "code1"}): with pytest.raises(Unauthorized): decode_jwt_token() - @patch("controllers.web.wraps.FeatureService.get_system_features") + @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.PassportService") @patch("controllers.web.wraps.extract_webapp_passport") def test_missing_app_raises_not_found( @@ -289,13 +289,13 @@ class TestDecodeJwtToken: "app_id": non_existent_id, "end_user_id": str(uuid4()), } - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) + mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": "code1"}): with pytest.raises(NotFound): decode_jwt_token() - @patch("controllers.web.wraps.FeatureService.get_system_features") + @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.PassportService") @patch("controllers.web.wraps.extract_webapp_passport") def test_disabled_site_raises_bad_request( @@ -314,13 +314,13 @@ class TestDecodeJwtToken: "app_id": app_model.id, "end_user_id": end_user.id, } - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) + mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": site.code}): with pytest.raises(BadRequest, match="Site is disabled"): decode_jwt_token() - @patch("controllers.web.wraps.FeatureService.get_system_features") + @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.PassportService") @patch("controllers.web.wraps.extract_webapp_passport") def test_missing_end_user_raises_not_found( @@ -340,13 +340,13 @@ class TestDecodeJwtToken: "app_id": app_model.id, "end_user_id": non_existent_eu, } - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) + mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": site.code}): with pytest.raises(NotFound): decode_jwt_token() - @patch("controllers.web.wraps.FeatureService.get_system_features") + @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.PassportService") @patch("controllers.web.wraps.extract_webapp_passport") def test_user_id_mismatch_raises_unauthorized( @@ -365,7 +365,7 @@ class TestDecodeJwtToken: "app_id": app_model.id, "end_user_id": end_user.id, } - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) + mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": site.code}): with pytest.raises(Unauthorized, match="expired"): diff --git a/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py b/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py index 1b9a39ac891..44d7e2e4d21 100644 --- a/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py +++ b/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py @@ -626,10 +626,10 @@ class TestKnowledgeRetrievalIntegration: @pytest.fixture def mock_external_service_dependencies(): with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True yield { "account_feature_service": mock_account_feature_service, diff --git a/api/tests/test_containers_integration_tests/models/test_dataset_models.py b/api/tests/test_containers_integration_tests/models/test_dataset_models.py index a3bbf196576..d817894b1de 100644 --- a/api/tests/test_containers_integration_tests/models/test_dataset_models.py +++ b/api/tests/test_containers_integration_tests/models/test_dataset_models.py @@ -49,7 +49,7 @@ class TestDatasetDocumentProperties: db_session_with_containers.add(doc) db_session_with_containers.flush() - assert dataset.total_documents == 3 + assert dataset.get_total_documents(session=db_session_with_containers) == 3 def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None: """Test dataset can count available documents.""" @@ -104,7 +104,7 @@ class TestDatasetDocumentProperties: db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled]) db_session_with_containers.flush() - assert dataset.total_available_documents == 1 + assert dataset.get_total_available_documents(session=db_session_with_containers) == 1 def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None: """Test dataset can aggregate word count from documents.""" @@ -426,7 +426,7 @@ class TestDocumentSegmentNavigationProperties: db_session_with_containers.flush() # Act - prev_seg = segment.previous_segment + prev_seg = segment.previous_segment(session=db_session_with_containers) # Assert assert prev_seg is not None @@ -483,7 +483,7 @@ class TestDocumentSegmentNavigationProperties: db_session_with_containers.flush() # Act - next_seg = segment.next_segment + next_seg = segment.next_segment(session=db_session_with_containers) # Assert assert next_seg is not None diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 37dbd20e81f..ab72c435cac 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -30,12 +30,12 @@ class TestAccountService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_feature_service, + patch("services.account_service.SystemFeatureService") as mock_feature_service, patch("services.account_service.BillingService") as mock_billing_service, patch("services.account_service.PassportService") as mock_passport_service, ): # Setup default mock returns - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True mock_feature_service.is_workspace_creation_allowed.return_value = True mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True mock_feature_service.get_license.return_value.seats.is_available.return_value = True @@ -57,7 +57,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False account = AccountService.create_account( @@ -84,7 +84,7 @@ class TestAccountService: email = fake.email() name = fake.name() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False account = AccountService.create_account( @@ -108,7 +108,7 @@ class TestAccountService: email = fake.email() name = fake.name() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Test with too short password (assuming minimum length validation) @@ -131,7 +131,7 @@ class TestAccountService: email = fake.email() name = fake.name() # Setup mocks to disable registration - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = False + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = False with pytest.raises(AccountNotFound): # AccountNotFound exception AccountService.create_account( @@ -153,7 +153,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True dify_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD @@ -189,7 +189,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account first @@ -219,7 +219,7 @@ class TestAccountService: correct_password = generate_valid_password(fake) wrong_password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account first @@ -245,7 +245,7 @@ class TestAccountService: name = fake.name() new_password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account without password @@ -280,7 +280,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account with pending status @@ -309,7 +309,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -342,7 +342,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -366,7 +366,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -393,7 +393,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies[ "feature_service" ].get_system_features.return_value.license.seats.is_available.return_value = False @@ -418,7 +418,7 @@ class TestAccountService: email = fake.email() name = fake.name() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -456,7 +456,7 @@ class TestAccountService: email = fake.email() name = fake.name() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -498,7 +498,7 @@ class TestAccountService: password = generate_valid_password(fake) ip_address = fake.ipv4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -529,7 +529,7 @@ class TestAccountService: password = generate_valid_password(fake) ip_address = fake.ipv4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token" @@ -568,7 +568,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token" @@ -599,7 +599,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token" @@ -634,7 +634,7 @@ class TestAccountService: password = generate_valid_password(fake) tenant_name = fake.company() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "new_mock_access_token" @@ -683,7 +683,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token" @@ -718,7 +718,7 @@ class TestAccountService: password = generate_valid_password(fake) tenant_name = fake.company() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -759,7 +759,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -788,7 +788,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_jwt_token" @@ -824,7 +824,7 @@ class TestAccountService: password = generate_valid_password(fake) tenant_name = fake.company() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -859,7 +859,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -900,7 +900,7 @@ class TestAccountService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -946,7 +946,7 @@ class TestTenantService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_feature_service, + patch("services.account_service.SystemFeatureService") as mock_feature_service, patch("services.account_service.BillingService") as mock_billing_service, ): # Setup default mock returns @@ -2038,12 +2038,12 @@ class TestRegisterService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_feature_service, + patch("services.account_service.SystemFeatureService") as mock_feature_service, patch("services.account_service.BillingService") as mock_billing_service, patch("services.account_service.PassportService") as mock_passport_service, ): # Setup default mock returns - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True mock_feature_service.is_workspace_creation_allowed.return_value = True mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True mock_feature_service.get_license.return_value.seats.is_available.return_value = True @@ -2066,7 +2066,7 @@ class TestRegisterService: admin_password = generate_valid_password(fake) ip_address = fake.ipv4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False from models.model import DifySetup @@ -2115,7 +2115,7 @@ class TestRegisterService: admin_password = generate_valid_password(fake) ip_address = fake.ipv4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account to raise exception @@ -2157,7 +2157,7 @@ class TestRegisterService: password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2199,7 +2199,7 @@ class TestRegisterService: provider = fake.random_element(elements=("google", "github", "microsoft")) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2246,7 +2246,7 @@ class TestRegisterService: password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2290,7 +2290,7 @@ class TestRegisterService: password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -2327,7 +2327,7 @@ class TestRegisterService: password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2365,7 +2365,7 @@ class TestRegisterService: password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Execute registration without workspace creation @@ -2404,7 +2404,7 @@ class TestRegisterService: new_member_email = fake.email() language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2476,7 +2476,7 @@ class TestRegisterService: existing_member_password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and inviter account @@ -2550,7 +2550,7 @@ class TestRegisterService: existing_pending_member_password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and inviter account @@ -2611,7 +2611,7 @@ class TestRegisterService: new_member_email = fake.email() language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant @@ -2644,7 +2644,7 @@ class TestRegisterService: already_in_tenant_password = generate_valid_password(fake) language = fake.random_element(elements=("en-US", "zh-CN")) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and inviter account @@ -2696,7 +2696,7 @@ class TestRegisterService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account @@ -2741,7 +2741,7 @@ class TestRegisterService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account @@ -2789,7 +2789,7 @@ class TestRegisterService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account @@ -2833,7 +2833,7 @@ class TestRegisterService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account @@ -2877,7 +2877,7 @@ class TestRegisterService: name = fake.name() password = generate_valid_password(fake) # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account @@ -2950,7 +2950,7 @@ class TestRegisterService: invalid_tenant_id = fake.uuid4() token = fake.uuid4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create account @@ -3002,7 +3002,7 @@ class TestRegisterService: password = generate_valid_password(fake) token = fake.uuid4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account @@ -3054,7 +3054,7 @@ class TestRegisterService: password = generate_valid_password(fake) token = fake.uuid4() # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and account diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index 445f2704641..d6e0ff103c1 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -26,10 +26,10 @@ class TestAgentService: patch("services.agent_service.ToolManager", autospec=True) as mock_tool_manager, patch("services.agent_service.AgentConfigManager", autospec=True) as mock_agent_config_manager, patch("services.agent_service.current_user", create_autospec(Account, instance=True)) as mock_current_user, - patch("services.app_service.FeatureService", autospec=True) as mock_feature_service, + patch("services.app_service.SystemFeatureService", autospec=True) as mock_feature_service, patch("services.app_service.EnterpriseService", autospec=True) as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant", autospec=True) as mock_model_manager, - patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service, + patch("services.account_service.SystemFeatureService", autospec=True) as mock_account_feature_service, ): # Setup default mock returns for agent service mock_plugin_agent_client_instance = mock_plugin_agent_client.return_value @@ -67,12 +67,12 @@ class TestAgentService: mock_current_user.timezone = "UTC" # Setup default mock returns for app service - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for model configuration mock_model_instance = mock_model_manager.return_value @@ -104,9 +104,7 @@ class TestAgentService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/services/test_annotation_service.py b/api/tests/test_containers_integration_tests/services/test_annotation_service.py index b416aee33fe..f9e66d91e78 100644 --- a/api/tests/test_containers_integration_tests/services/test_annotation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_annotation_service.py @@ -21,7 +21,7 @@ class TestAnnotationService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, patch("services.annotation_service.FeatureService") as mock_feature_service, patch("services.annotation_service.add_annotation_to_index_task") as mock_add_task, patch("services.annotation_service.update_annotation_to_index_task") as mock_update_task, @@ -70,9 +70,7 @@ class TestAnnotationService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant first from services.account_service import AccountService, TenantService diff --git a/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py b/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py index de51f5077e6..ba0a3c2707a 100644 --- a/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py +++ b/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py @@ -17,7 +17,7 @@ class TestAPIBasedExtensionService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, patch("services.api_based_extension_service.APIBasedExtensionRequestor") as mock_requestor, ): # Setup default mock returns @@ -47,9 +47,7 @@ class TestAPIBasedExtensionService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index e61b22e611a..eea8ab9c2a8 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -122,7 +122,7 @@ class TestAppDslService: patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service, patch("services.app_dsl_service.app_was_created") as mock_app_was_created, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, ): mock_workflow_service.return_value.get_draft_workflow.return_value = None @@ -139,7 +139,7 @@ class TestAppDslService: "gpt-3.5-turbo", ) - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None yield { @@ -153,8 +153,8 @@ class TestAppDslService: def _create_test_app_and_account(self, db_session_with_containers: Session, mock_external_service_dependencies): fake = Faker() - with patch("services.account_service.FeatureService") as mock_account_feature_service: - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + with patch("services.account_service.SystemFeatureService") as mock_account_feature_service: + mock_account_feature_service.is_registration_allowed.return_value = True account = AccountService.create_account( email=fake.email(), name=fake.name(), diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index d9f81caf01c..7e27e66f10c 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -38,7 +38,7 @@ class TestAppGenerateService: patch( "services.app_generate_service.MessageBasedAppGenerator", autospec=True ) as mock_message_based_generator, - patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service, + patch("services.account_service.SystemFeatureService", autospec=True) as mock_account_feature_service, patch("services.app_generate_service.dify_config") as mock_dify_config, patch("services.quota_service.dify_config") as mock_quota_dify_config, patch("configs.dify_config") as mock_global_dify_config, @@ -104,7 +104,7 @@ class TestAppGenerateService: mock_message_based_generator.retrieve_events.return_value = ["workflow_events"] # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Setup dify_config mock returns mock_dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY @@ -155,9 +155,7 @@ class TestAppGenerateService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant from services.account_service import AccountService, TenantService diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 44006359acd..4c50966777d 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -25,18 +25,18 @@ class TestAppService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for app service - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for model configuration mock_model_instance = mock_model_manager.return_value @@ -1252,9 +1252,7 @@ class TestAppService: app_id = app.id # Mock webapp auth cleanup - mock_external_service_dependencies[ - "feature_service" - ].get_system_features.return_value.webapp_auth.enabled = True + mock_external_service_dependencies["feature_service"].is_webapp_auth_enabled.return_value = True # Mock the async deletion task with patch("services.app_service.remove_app_and_related_data_task") as mock_delete_task: diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index ca933a08462..99c8dd9b322 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -14,6 +14,7 @@ from services.entities.feature_entities import ( SystemFeatureModel, ) from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService class TestFeatureService: @@ -25,6 +26,7 @@ class TestFeatureService: with ( patch("services.feature_service.BillingService") as mock_billing_service, patch("services.feature_service.EnterpriseService") as mock_enterprise_service, + patch("services.system_feature_service.EnterpriseService", new=mock_enterprise_service), ): # Setup default mock returns for BillingService mock_billing_service.get_info.return_value = { @@ -273,7 +275,7 @@ class TestFeatureService: # Arrange: Setup test data with proper config tenant_id = self._create_test_tenant_id() - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = True @@ -285,7 +287,7 @@ class TestFeatureService: mock_config.MAIL_TYPE = "smtp" # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -345,7 +347,7 @@ class TestFeatureService: - The response structure adheres to the public schema for unauthenticated clients. """ # Arrange: Setup test data with exact same config as success test - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = True @@ -357,7 +359,7 @@ class TestFeatureService: mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100 # Act: Execute the public (unauthenticated) system-features call - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Basic structure assert result is not None @@ -399,11 +401,11 @@ class TestFeatureService: - Detail withheld from the public system-features model is present here. """ # Arrange - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE # Act - result = FeatureService.get_license() + result = SystemFeatureService.get_license() # Assert: full license detail is populated assert isinstance(result, LicenseModel) @@ -418,10 +420,10 @@ class TestFeatureService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """Non-enterprise deployments have no license, so limits are unconstrained.""" - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - result = FeatureService.get_license() + result = SystemFeatureService.get_license() assert isinstance(result, LicenseModel) assert result.status == LicenseStatus.NONE @@ -442,7 +444,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup basic config mock (no enterprise) - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = True @@ -456,7 +458,7 @@ class TestFeatureService: mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100 # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -640,7 +642,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Use the Community edition. - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -653,7 +655,7 @@ class TestFeatureService: mock_config.PLUGIN_MAX_PACKAGE_SIZE = 50 # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -863,7 +865,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup edge case webapp auth mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -879,7 +881,7 @@ class TestFeatureService: } # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -981,7 +983,7 @@ class TestFeatureService: """ # Test case 1: Official only scope - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -999,12 +1001,12 @@ class TestFeatureService: } } - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.plugin_installation_permission.plugin_installation_scope == "official_only" assert result.plugin_installation_permission.restrict_to_marketplace_only is True # Test case 2: All plugins scope - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1019,12 +1021,12 @@ class TestFeatureService: "PluginInstallationPermission": {"pluginInstallationScope": "all", "restrictToMarketplaceOnly": False} } - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.plugin_installation_permission.plugin_installation_scope == "all" assert result.plugin_installation_permission.restrict_to_marketplace_only is False # Test case 3: Specific partners scope - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1042,12 +1044,12 @@ class TestFeatureService: } } - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.plugin_installation_permission.plugin_installation_scope == "official_and_specific_partners" assert result.plugin_installation_permission.restrict_to_marketplace_only is False # Test case 4: None scope - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1062,7 +1064,7 @@ class TestFeatureService: "PluginInstallationPermission": {"pluginInstallationScope": "none", "restrictToMarketplaceOnly": True} } - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.plugin_installation_permission.plugin_installation_scope == "none" assert result.plugin_installation_permission.restrict_to_marketplace_only is True @@ -1120,7 +1122,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup inactive license mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1140,7 +1142,7 @@ class TestFeatureService: } # Act: Execute the authenticated license accessor - result = FeatureService.get_license() + result = SystemFeatureService.get_license() # Assert: Verify the expected outcomes assert result is not None @@ -1169,7 +1171,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup partial enterprise info mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1187,7 +1189,7 @@ class TestFeatureService: } # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -1297,7 +1299,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup edge case protocols mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1315,7 +1317,7 @@ class TestFeatureService: } # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -1448,7 +1450,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup expired license mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1468,7 +1470,7 @@ class TestFeatureService: } # Act: Execute the authenticated license accessor - result = FeatureService.get_license() + result = SystemFeatureService.get_license() # Assert: Verify the expected outcomes assert result is not None @@ -1554,7 +1556,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup edge case branding mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1575,7 +1577,7 @@ class TestFeatureService: } # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -1740,7 +1742,7 @@ class TestFeatureService: - Return value correctness and structure """ # Arrange: Setup lost license mock with proper config - with patch("services.feature_service.dify_config") as mock_config: + with patch("services.system_feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1756,7 +1758,7 @@ class TestFeatureService: } # Act: Execute the method under test - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() # Assert: Verify the expected outcomes assert result is not None diff --git a/api/tests/test_containers_integration_tests/services/test_message_service.py b/api/tests/test_containers_integration_tests/services/test_message_service.py index 702812b96de..62d03690ee9 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service.py @@ -24,7 +24,7 @@ class TestMessageService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, patch("services.message_service.ModelManager.for_tenant") as mock_model_manager, patch("services.message_service.WorkflowService") as mock_workflow_service, patch("services.message_service.AdvancedChatAppConfigManager") as mock_app_config_manager, @@ -86,9 +86,7 @@ class TestMessageService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant first from services.account_service import AccountService, TenantService diff --git a/api/tests/test_containers_integration_tests/services/test_ops_service.py b/api/tests/test_containers_integration_tests/services/test_ops_service.py index b4b8521fb2e..029c6ba9420 100644 --- a/api/tests/test_containers_integration_tests/services/test_ops_service.py +++ b/api/tests/test_containers_integration_tests/services/test_ops_service.py @@ -20,15 +20,15 @@ class TestOpsService: @pytest.fixture def mock_external_service_dependencies(self): with ( - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True mock_model_instance = mock_model_manager.return_value mock_model_instance.get_default_model_instance.return_value = None mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") diff --git a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py index 92741ac56cb..a55ed6c9532 100644 --- a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py @@ -20,12 +20,12 @@ class TestSavedMessageService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, patch("services.saved_message_service.MessageService") as mock_message_service, ): # Setup default mock returns - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for app creation mock_model_instance = mock_model_manager.return_value @@ -56,9 +56,7 @@ class TestSavedMessageService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant first from services.account_service import AccountService, TenantService diff --git a/api/tests/test_containers_integration_tests/services/test_trigger_provider_service.py b/api/tests/test_containers_integration_tests/services/test_trigger_provider_service.py index c464505ef9e..90160d5a963 100644 --- a/api/tests/test_containers_integration_tests/services/test_trigger_provider_service.py +++ b/api/tests/test_containers_integration_tests/services/test_trigger_provider_service.py @@ -27,7 +27,7 @@ class TestTriggerProviderService: patch("services.trigger.trigger_provider_service.TriggerManager") as mock_trigger_manager, patch("services.trigger.trigger_provider_service.redis_client") as mock_redis_client, patch("services.trigger.trigger_provider_service.delete_cache_for_subscription") as mock_delete_cache, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns mock_provider_controller = MagicMock() @@ -42,7 +42,7 @@ class TestTriggerProviderService: mock_redis_client.lock.return_value = mock_lock # Setup account feature service mock - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True yield { "trigger_manager": mock_trigger_manager, @@ -71,9 +71,7 @@ class TestTriggerProviderService: from services.account_service import AccountService, TenantService # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies[ "trigger_manager" ].get_trigger_provider.return_value = mock_external_service_dependencies["provider_controller"] diff --git a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py index ed063ceaccc..484f093ed47 100644 --- a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py @@ -23,18 +23,18 @@ class TestWebConversationService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for app service - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for model configuration mock_model_instance = mock_model_manager.return_value @@ -62,9 +62,7 @@ class TestWebConversationService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py index d47eaa0f8c5..d63ba1190ec 100644 --- a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py @@ -11,7 +11,7 @@ from libs.password import hash_password from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole from models.model import App, Site from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError -from services.webapp_auth_service import WebAppAuthService, WebAppAuthType +from services.webapp_auth_service import WebAppAuthService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -825,90 +825,3 @@ class TestWebAppAuthService: WebAppAuthService.is_app_require_permission_check(session=db_session_with_containers) assert "Either app_code or app_id must be provided." in str(exc_info.value) - - def test_get_app_auth_type_with_access_mode_public( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test app authentication type for public access mode. - - This test verifies: - - Proper authentication type determination for public mode - - Correct return value - - Mock service integration - """ - # Arrange: Setup test with public access mode - - # Act: Execute authentication type determination - result = WebAppAuthService.get_app_auth_type(access_mode="public", session=db_session_with_containers) - - # Assert: Verify correct result - assert result == WebAppAuthType.PUBLIC - - def test_get_app_auth_type_with_access_mode_private( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test app authentication type for private access mode. - - This test verifies: - - Proper authentication type determination for private mode - - Correct return value - - Mock service integration - """ - # Arrange: Setup test with private access mode - - # Act: Execute authentication type determination - result = WebAppAuthService.get_app_auth_type(access_mode="private", session=db_session_with_containers) - - # Assert: Verify correct result - assert result == WebAppAuthType.INTERNAL - - def test_get_app_auth_type_with_app_code( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test app authentication type using app code. - - This test verifies: - - Proper authentication type determination using app code - - Correct return value - - Mock service integration - """ - # Arrange: Setup mock for enterprise service - mock_external_service_dependencies["app_service"].get_app_id_by_code.return_value = "mock_app_id" - setting = type("MockWebAppAuth", (), {"access_mode": "sso_verified"})() - mock_external_service_dependencies[ - "enterprise_service" - ].WebAppAuth.get_app_access_mode_by_id.return_value = setting - - # Act: Execute authentication type determination - result: WebAppAuthType = WebAppAuthService.get_app_auth_type( - app_code="mock_app_code", session=db_session_with_containers - ) - - # Assert: Verify correct result - assert result == WebAppAuthType.EXTERNAL - - # Verify mock service was called correctly - mock_external_service_dependencies[ - "enterprise_service" - ].WebAppAuth.get_app_access_mode_by_id.assert_called_once_with(app_id="mock_app_id") - - def test_get_app_auth_type_no_parameters( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test app authentication type with no parameters. - - This test verifies: - - Proper error handling when no parameters provided - - Correct exception type and message - """ - # Arrange: No parameters provided - - # Act & Assert: Verify proper error handling - with pytest.raises(ValueError) as exc_info: - WebAppAuthService.get_app_auth_type(session=db_session_with_containers) - - assert "Either app_code or access_mode must be provided." in str(exc_info.value) diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service.py b/api/tests/test_containers_integration_tests/services/test_webhook_service.py index a7a06362cad..0f67408cb03 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service.py @@ -8,14 +8,12 @@ from faker import Faker from flask import Flask from sqlalchemy.orm import Session -from enums import DeploymentEdition from models.account import Account, Tenant from models.enums import AppTriggerStatus, AppTriggerType from models.model import App from models.trigger import AppTrigger, WorkflowWebhookTrigger from models.workflow import Workflow from services.account_service import AccountService, TenantService -from services.entities.feature_entities import SystemFeatureModel from services.trigger.webhook_service import WebhookService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -38,16 +36,12 @@ def test_data( """Persist the webhook graph with account and workspace creation enabled.""" fake = Faker() - system_features = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - is_allow_register=True, + monkeypatch.setattr( + "services.account_service.SystemFeatureService.is_registration_allowed", + lambda: True, ) monkeypatch.setattr( - "services.account_service.FeatureService.get_system_features", - lambda: system_features, - ) - monkeypatch.setattr( - "services.account_service.FeatureService.is_workspace_creation_allowed", + "services.account_service.SystemFeatureService.is_workspace_creation_allowed", lambda: True, ) account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py index 78cdb9740ce..617c27f41a6 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py @@ -28,18 +28,18 @@ class TestWorkflowAppService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for app service - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for model configuration mock_model_instance = mock_model_manager.return_value @@ -67,9 +67,7 @@ class TestWorkflowAppService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( @@ -116,9 +114,7 @@ class TestWorkflowAppService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py index 7c528f06b10..263f41e5144 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py @@ -25,18 +25,18 @@ class TestWorkflowRunService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for app service - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for model configuration mock_model_instance = mock_model_manager.return_value @@ -64,9 +64,7 @@ class TestWorkflowRunService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py index b12472c586c..c2c5fe838fb 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py @@ -23,10 +23,10 @@ class TestWorkflowToolManageService: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.SystemFeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, patch( "services.tools.workflow_tools_manage_service.WorkflowToolProviderController" ) as mock_workflow_tool_provider_controller, @@ -34,12 +34,12 @@ class TestWorkflowToolManageService: patch("services.tools.workflow_tools_manage_service.ToolTransformService") as mock_tool_transform_service, ): # Setup default mock returns for app service - mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_feature_service.is_webapp_auth_enabled.return_value = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True # Mock ModelManager for model configuration mock_model_instance = mock_model_manager.return_value @@ -79,9 +79,7 @@ class TestWorkflowToolManageService: fake = Faker() # Setup mocks for account creation - mock_external_service_dependencies[ - "account_feature_service" - ].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True # Create account and tenant account = AccountService.create_account( diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py index b8327db67f4..b2007608e42 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py @@ -37,10 +37,10 @@ class TestCleanNotionDocumentTask: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True yield { "account_feature_service": mock_account_feature_service, diff --git a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py index b0704ae9303..6499d251048 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py @@ -29,10 +29,10 @@ class TestDealDatasetVectorIndexTask: def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.account_service.SystemFeatureService") as mock_account_feature_service, ): # Setup default mock returns for account service - mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_account_feature_service.is_registration_allowed.return_value = True yield { "account_feature_service": mock_account_feature_service, diff --git a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py index c08b0be6a04..7bc578ed6a5 100644 --- a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py +++ b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py @@ -41,7 +41,7 @@ from models.trigger import ( from models.workflow import Workflow from schedule import workflow_schedule_task from schedule.workflow_schedule_task import poll_workflow_schedules -from services import feature_service as feature_service_module +from services.system_feature_service import SystemFeatureService from services.trigger import webhook_service from services.trigger.schedule_service import ScheduleService from services.workflow_service import WorkflowService @@ -112,7 +112,7 @@ def test_publish_blocks_start_and_trigger_coexistence( workflow_service = WorkflowService() monkeypatch.setattr( - feature_service_module.FeatureService, + SystemFeatureService, "is_plugin_manager_enabled", classmethod(lambda _cls: False), ) diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 27a543fd2a2..f39a8e8a3c9 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from datetime import datetime from inspect import getsource, unwrap from types import SimpleNamespace @@ -309,15 +308,9 @@ def account_id() -> str: def test_agent_app_list_and_create_use_agent_route( - app: Flask, - monkeypatch: pytest.MonkeyPatch, - account_id: str, - sqlite_session: Session, - config_overrides: Callable[..., None], + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session ) -> None: captured: dict[str, object] = {} - replace_whitelist = MagicMock() - initialize_access = MagicMock() class FakeAppService: def get_app(self, app_obj: object, *, session: object) -> object: @@ -403,9 +396,7 @@ def test_agent_app_list_and_create_use_agent_route( lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, ) monkeypatch.setattr( - roster_controller.AgentRosterService, - "count_agent_app_debug_conversation_messages", - lambda _self, **kwargs: 0, + roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 ) def get_or_create_debug_conversation(_self: object, **kwargs: object) -> str: @@ -418,17 +409,10 @@ def test_agent_app_list_and_create_use_agent_route( get_or_create_debug_conversation, ) monkeypatch.setattr( - roster_controller.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + roster_controller.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) - config_overrides(RBAC_ENABLED=True) - monkeypatch.setattr( - roster_controller.enterprise_rbac_service.RBACService.AppAccess, - "replace_whitelist", - replace_whitelist, - ) - monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access) with app.test_request_context( "/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created" "&is_created_by_me=true&publication_status=published" @@ -469,22 +453,12 @@ def test_agent_app_list_and_create_use_agent_route( assert count_params.agent_is_published is True with app.test_request_context( "/console/api/agent", - json={ - "name": "Iris", - "description": "Agent app", - "role": "Coordinator", - "icon_type": "emoji", - "icon": "robot", - }, + json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, ): created, status = unwrap(AgentAppListApi.post)( AgentAppListApi(), AgentAppCreatePayload( - name="Iris", - description="Agent app", - role="Coordinator", - icon_type="emoji", - icon="robot", + name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot" ), sqlite_session, "tenant-1", @@ -507,81 +481,6 @@ def test_agent_app_list_and_create_use_agent_route( "account_id": account_id, "commit": False, } - replace_whitelist.assert_called_once() - assert replace_whitelist.call_args.args[:3] == ("tenant-1", account_id, "app-created") - replace_payload = replace_whitelist.call_args.args[3] - assert replace_payload.automatic_include_workspace_members is True - initialize_access.assert_called_once_with("tenant-1", account_id, app_id="app-created") - - -def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled( - app: Flask, - monkeypatch: pytest.MonkeyPatch, - account_id: str, - sqlite_session: Session, - config_overrides: Callable[..., None], -) -> None: - replace_whitelist = MagicMock() - initialize_access = MagicMock() - - class FakeAppService: - def get_app(self, app_obj: object, *, session: object) -> object: - return app_obj - - def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object: - return _app_detail_obj(id="app-created", bound_agent_id="agent-created") - - monkeypatch.setattr(roster_controller, "AppService", FakeAppService) - monkeypatch.setattr( - roster_controller.AgentRosterService, - "get_app_backing_agent", - lambda _self, **kwargs: Agent( - id="agent-created", - app_id="app-created", - backing_app_id=None, - role="Created role", - active_config_snapshot_id=None, - ), - ) - monkeypatch.setattr( - roster_controller.AgentRosterService, - "get_or_create_build_conversation", - lambda _self, **kwargs: "debug-conversation-created", - ) - monkeypatch.setattr( - roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 - ) - monkeypatch.setattr( - roster_controller.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), - ) - config_overrides(RBAC_ENABLED=False) - monkeypatch.setattr( - roster_controller.enterprise_rbac_service.RBACService.AppAccess, - "replace_whitelist", - replace_whitelist, - ) - monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access) - - with app.test_request_context( - "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, - ): - created, status = unwrap(AgentAppListApi.post)( - AgentAppListApi(), - AgentAppCreatePayload( - name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot" - ), - sqlite_session, - "tenant-1", - _account(account_id=account_id), - ) - - assert status == 201 - assert created["id"] == "agent-created" - replace_whitelist.assert_not_called() - initialize_access.assert_not_called() def test_agent_app_create_payload_allows_optional_role() -> None: @@ -666,9 +565,9 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 2 ) monkeypatch.setattr( - roster_controller.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + roster_controller.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) monkeypatch.setattr( roster_controller, @@ -1126,9 +1025,9 @@ def test_agent_app_update_allows_empty_role( roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 ) monkeypatch.setattr( - roster_controller.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + roster_controller.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) class FakeAppService: diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index 5d1040bc149..ae0f807a715 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -149,10 +149,6 @@ def _account() -> Account: @pytest.mark.parametrize( "method", [ - module.AgentAppSandboxInfoResource.get, - module.AgentAppSandboxListResource.get, - module.AgentAppSandboxReadResource.get, - module.AgentAppSandboxDownloadResource.post, module.WorkflowAgentSandboxListResource.get, module.WorkflowAgentSandboxReadResource.get, module.WorkflowAgentSandboxDownloadResource.post, diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 16c2ae1007d..c2d6a756062 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -13,14 +13,12 @@ from sqlalchemy import Engine, event from sqlalchemy.orm import Session from controllers.console.app import app_import as app_import_module -from enums import DeploymentEdition from models.account import Account, Tenant from models.base import TypeBase from models.engine import db from models.model import App, AppMode from services.app_dsl_service import ImportStatus from services.entities.dsl_entities import CheckDependenciesResult -from services.entities.feature_entities import SystemFeatureModel, WebAppAuthModel from tests.unit_tests.config_override import apply_config_overrides @@ -49,11 +47,7 @@ class _Result: def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: - features = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - webapp_auth=WebAppAuthModel(enabled=enabled), - ) - monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features) + monkeypatch.setattr(app_import_module.SystemFeatureService, "is_webapp_auth_enabled", lambda: enabled) def _make_account(account_id: str = "u1") -> Account: diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index b8a5ec91f40..2c7a6ca9e90 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -563,8 +563,8 @@ def test_app_list_uses_injected_session_for_draft_workflows( ) monkeypatch.setattr( app_module, - "FeatureService", - SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))), + "SystemFeatureService", + SimpleNamespace(is_webapp_auth_enabled=lambda: False), ) get_permissions = MagicMock( return_value=app_module.enterprise_rbac_service.MyPermissionsResponse( @@ -680,9 +680,9 @@ def test_app_list_api_attaches_permission_keys( get_paginate_apps, ) monkeypatch.setattr( - app_module.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + app_module.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) monkeypatch.setattr( app_module.enterprise_rbac_service.RBACService.MyPermissions, @@ -865,9 +865,9 @@ def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permis lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-shared", "app-not-permitted"]), ) monkeypatch.setattr( - app_module.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + app_module.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session) @@ -922,9 +922,9 @@ def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission( lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-whitelist-only"]), ) monkeypatch.setattr( - app_module.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + app_module.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session) @@ -960,9 +960,9 @@ def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permiss lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-not-permitted"]), ) monkeypatch.setattr( - app_module.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + app_module.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session) @@ -996,9 +996,9 @@ def test_app_detail_api_attaches_current_user_permission_keys( get_app = MagicMock(return_value=app_obj) monkeypatch.setattr(app_module, "AppService", lambda: SimpleNamespace(get_app=get_app)) monkeypatch.setattr( - app_module.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + app_module.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) get_permissions = MagicMock( return_value=app_module.enterprise_rbac_service.MyPermissionsResponse( @@ -1081,9 +1081,9 @@ def test_app_copy_api_attaches_permission_keys( ), ) monkeypatch.setattr( - app_module.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + app_module.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) monkeypatch.setattr(app_module, "db", SimpleNamespace(engine=sqlite_engine)) monkeypatch.setattr( diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index 64e2813f62f..c5ea4d69ace 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -32,7 +32,7 @@ class TestAuthenticationSecurity: self.app.config["TESTING"] = True @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.FeatureService.get_system_features") + @patch("controllers.console.auth.login.SystemFeatureService.is_registration_allowed") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") @@ -46,7 +46,7 @@ class TestAuthenticationSecurity: mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") - mock_features.return_value.is_allow_register = True + mock_features.return_value = True # Act with self.app.test_request_context( @@ -96,7 +96,7 @@ class TestAuthenticationSecurity: mock_add_rate_limit.assert_called_once_with("existing@example.com") @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.FeatureService.get_system_features") + @patch("controllers.console.auth.login.SystemFeatureService.is_registration_allowed") @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") @@ -110,7 +110,7 @@ class TestAuthenticationSecurity: mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") - mock_features.return_value.is_allow_register = False + mock_features.return_value = False # Act with self.app.test_request_context( @@ -129,7 +129,7 @@ class TestAuthenticationSecurity: mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.FeatureService.get_system_features") + @patch("controllers.console.auth.login.SystemFeatureService.is_registration_allowed") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.AccountService.send_reset_password_email") def test_reset_password_with_existing_account(self, mock_send_email, mock_get_user, mock_features, mock_db): diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register.py b/api/tests/unit_tests/controllers/console/auth/test_email_register.py index 7b5f859877b..2189682f78a 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register.py @@ -51,7 +51,6 @@ from services.account_errors import ( InvalidEmailRegistrationTokenError, ) from services.entities.account_entities import AccountEmailRegistrationVerification, AccountSessionTokens -from services.entities.feature_entities import SystemFeatureModel @pytest.fixture(autouse=True) @@ -68,14 +67,16 @@ def _request( payload: dict[str, str], ) -> Generator[None, None, None]: services = SimpleNamespace(accounts=SimpleNamespace(email_registration=service)) - features = SystemFeatureModel( - deployment_edition=DeploymentEdition.CLOUD, - enable_email_password_login=True, - is_allow_register=True, - ) with ( patch("controllers.console.auth.email_register.application_services", return_value=services), - patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features), + patch( + "controllers.console.flask_admission.SystemFeatureService.is_email_password_login_enabled", + return_value=True, + ), + patch( + "controllers.console.flask_admission.SystemFeatureService.is_registration_allowed", + return_value=True, + ), patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1"), app.test_request_context(path, method="POST", json=payload), ): @@ -281,14 +282,18 @@ def test_invalid_password_is_sanitized_by_real_error_handler(caplog: pytest.LogC app = Flask(__name__) app.config["TESTING"] = True app.register_blueprint(console_bp) - features = SystemFeatureModel( - deployment_edition=DeploymentEdition.CLOUD, - enable_email_password_login=True, - is_allow_register=True, - ) password_marker = "SecretMarker" - with patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features): + with ( + patch( + "controllers.console.flask_admission.SystemFeatureService.is_email_password_login_enabled", + return_value=True, + ), + patch( + "controllers.console.flask_admission.SystemFeatureService.is_registration_allowed", + return_value=True, + ), + ): response = app.test_client().post( "/console/api/email-register", json={ diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index 930ac17279b..aedea3033e7 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -146,7 +146,7 @@ class TestEmailCodeLoginSendEmailApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit") @patch("controllers.console.auth.login.AccountService.get_user_through_email") - @patch("controllers.console.auth.login.FeatureService.get_system_features") + @patch("controllers.console.auth.login.SystemFeatureService.is_registration_allowed") @patch("controllers.console.auth.login.AccountService.send_email_code_login_email") def test_send_email_code_new_user_registration_allowed( self, mock_send_email, mock_get_features, mock_get_user, mock_is_ip_limit, mock_db, app @@ -161,7 +161,7 @@ class TestEmailCodeLoginSendEmailApi: # Arrange mock_is_ip_limit.return_value = False mock_get_user.return_value = None - mock_get_features.return_value.is_allow_register = True + mock_get_features.return_value = True mock_send_email.return_value = "email_token_123" # Act @@ -178,7 +178,7 @@ class TestEmailCodeLoginSendEmailApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit") @patch("controllers.console.auth.login.AccountService.get_user_through_email") - @patch("controllers.console.auth.login.FeatureService.get_system_features") + @patch("controllers.console.auth.login.SystemFeatureService.is_registration_allowed") def test_send_email_code_new_user_registration_disabled( self, mock_get_features, mock_get_user, mock_is_ip_limit, mock_db, app ): @@ -192,7 +192,7 @@ class TestEmailCodeLoginSendEmailApi: # Arrange mock_is_ip_limit.return_value = False mock_get_user.return_value = None - mock_get_features.return_value.is_allow_register = False + mock_get_features.return_value = False # Act & Assert with app.test_request_context("/email-code-login", method="POST", json={"email": "newuser@example.com"}): @@ -766,7 +766,7 @@ class TestEmailCodeLoginApi: @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") - @patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed") + @patch("controllers.console.auth.login.SystemFeatureService.is_workspace_creation_allowed") def test_email_code_login_creates_workspace_for_user_without_tenant( self, mock_is_workspace_creation_allowed, @@ -806,8 +806,8 @@ class TestEmailCodeLoginApi: @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") - @patch("controllers.console.auth.login.FeatureService.get_license") - @patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed") + @patch("controllers.console.auth.login.SystemFeatureService.get_license") + @patch("controllers.console.auth.login.SystemFeatureService.is_workspace_creation_allowed") def test_email_code_login_workspace_limit_exceeded( self, mock_is_workspace_creation_allowed, @@ -848,7 +848,7 @@ class TestEmailCodeLoginApi: @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") - @patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed") + @patch("controllers.console.auth.login.SystemFeatureService.is_workspace_creation_allowed") def test_email_code_login_workspace_creation_not_allowed( self, mock_is_workspace_creation_allowed, diff --git a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py index b9eda4eeb0a..658cad2c35b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py @@ -59,11 +59,14 @@ class TestForgotPasswordSendEmailApi: ) with ( patch( - "controllers.console.auth.forgot_password.FeatureService.get_system_features", - return_value=controller_features, + "controllers.console.auth.forgot_password.SystemFeatureService.is_registration_allowed", + return_value=controller_features.is_allow_register, ), config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), + patch( + "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", + return_value=wraps_features.enable_email_password_login, + ), ): with app.test_request_context( "/forgot-password", @@ -110,7 +113,10 @@ class TestForgotPasswordCheckApi: ) with ( config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), + patch( + "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", + return_value=wraps_features.enable_email_password_login, + ), ): with app.test_request_context( "/forgot-password/validity", @@ -156,7 +162,10 @@ class TestForgotPasswordResetApi: ) with ( config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), + patch( + "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", + return_value=wraps_features.enable_email_password_login, + ), ): with database_app.test_request_context( "/forgot-password/resets", diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index bec3305387b..61a78011987 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -479,8 +479,8 @@ class TestLoginApi: @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.TenantService.get_join_tenants") - @patch("controllers.console.auth.login.FeatureService.get_license") - @patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed") + @patch("controllers.console.auth.login.SystemFeatureService.get_license") + @patch("controllers.console.auth.login.SystemFeatureService.is_workspace_creation_allowed") def test_login_fails_when_no_workspace_and_limit_exceeded( self, mock_is_workspace_creation_allowed: MagicMock, diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py index 1d95860e1b4..3a0d1b24310 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -534,7 +534,7 @@ class TestAccountGeneration: ], ) @patch("controllers.console.auth.oauth._get_account_by_openid_or_email") - @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth.RegisterService") @patch("controllers.console.auth.oauth.AccountService") @patch("controllers.console.auth.oauth.TenantService") @@ -553,7 +553,7 @@ class TestAccountGeneration: should_create, ): mock_get_account.return_value = mock_account if existing_account else None - mock_feature_service.get_system_features.return_value.is_allow_register = allow_register + mock_feature_service.is_registration_allowed.return_value = allow_register mock_register_service.register.return_value = mock_account with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): @@ -590,7 +590,7 @@ class TestAccountGeneration: @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) @patch("controllers.console.auth.oauth.BillingService.get_email_freeze_type") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) - @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.SystemFeatureService") def test_should_reject_registration_for_frozen_email( self, mock_feature_service, @@ -601,7 +601,7 @@ class TestAccountGeneration: app: Flask, user_info: OAuthUserInfo, ): - mock_feature_service.get_system_features.return_value.is_allow_register = False + mock_feature_service.is_registration_allowed.return_value = False mock_get_freeze_type.return_value = freeze_type with app.test_request_context("/"): @@ -611,7 +611,7 @@ class TestAccountGeneration: mock_get_freeze_type.assert_called_once_with("test@example.com") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) - @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth.RegisterService") @patch("controllers.console.auth.oauth.AccountService") @patch("controllers.console.auth.oauth.TenantService") @@ -625,7 +625,7 @@ class TestAccountGeneration: app: Flask, ): user_info = OAuthUserInfo(id="123", name="Test User", email="Upper@Example.com") - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True mock_register_service.register.return_value = Account(name="Test User", email="upper@example.com") with app.test_request_context(headers={"Accept-Language": "en-US"}): @@ -644,7 +644,7 @@ class TestAccountGeneration: ) @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) - @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth.RegisterService") @patch("controllers.console.auth.oauth.AccountService") @patch("controllers.console.auth.oauth.TenantService") @@ -658,7 +658,7 @@ class TestAccountGeneration: app: Flask, user_info: OAuthUserInfo, ): - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True mock_register_service.register.return_value = Account(name="Test User", email="test@example.com") with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}): @@ -677,7 +677,7 @@ class TestAccountGeneration: ) @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) - @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth.RegisterService") @patch("controllers.console.auth.oauth.AccountService") @patch("controllers.console.auth.oauth.TenantService") @@ -691,7 +691,7 @@ class TestAccountGeneration: app: Flask, user_info: OAuthUserInfo, ): - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True mock_register_service.register.return_value = Account(name="Test User", email="test@example.com") with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): @@ -711,7 +711,7 @@ class TestAccountGeneration: @patch("controllers.console.auth.oauth._get_account_by_openid_or_email") @patch("controllers.console.auth.oauth.TenantService") - @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth.AccountService") def test_should_create_workspace_for_account_without_tenant( self, diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py index 74bd9ec0be4..f4a332305cf 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py @@ -42,7 +42,7 @@ def test_oauth_login_passes_language_and_timezone_to_authorization_url( @patch("controllers.console.auth.oauth.AccountService.link_account_integrate") @patch("controllers.console.auth.oauth.RegisterService") -@patch("controllers.console.auth.oauth.FeatureService") +@patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) def test_generate_account_registers_with_browser_timezone( mock_get_account, @@ -53,7 +53,7 @@ def test_generate_account_registers_with_browser_timezone( ): account = Account(name="Test User", email="user@example.com") mock_register_service.register.return_value = account - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com") with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}): @@ -79,7 +79,7 @@ def test_generate_account_registers_with_browser_timezone( @patch("controllers.console.auth.oauth.AccountService.link_account_integrate") @patch("controllers.console.auth.oauth.RegisterService") -@patch("controllers.console.auth.oauth.FeatureService") +@patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) def test_generate_account_prefers_state_language_over_accept_language( mock_get_account, @@ -90,7 +90,7 @@ def test_generate_account_prefers_state_language_over_accept_language( ): account = Account(name="Test User", email="user@example.com") mock_register_service.register.return_value = account - mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.is_registration_allowed.return_value = True user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com") with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): @@ -111,7 +111,7 @@ def test_generate_account_prefers_state_language_over_accept_language( @patch("controllers.console.auth.oauth.RegisterService") -@patch("controllers.console.auth.oauth.FeatureService") +@patch("controllers.console.auth.oauth.SystemFeatureService") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) def test_generate_account_rejects_new_user_when_registration_disabled( mock_get_account, @@ -120,7 +120,7 @@ def test_generate_account_rejects_new_user_when_registration_disabled( app: Flask, config_overrides, ): - mock_feature_service.get_system_features.return_value.is_allow_register = False + mock_feature_service.is_registration_allowed.return_value = False config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) user_info = OAuthUserInfo(id="github-123", name="Test User", email="user@example.com") diff --git a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py index 0db3b031cd9..57f6196c8d6 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -49,7 +49,7 @@ def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( - "controllers.console.wraps.FeatureService.get_system_features", + "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", lambda: SystemFeatureModel( deployment_edition=DeploymentEdition.COMMUNITY, enable_email_password_login=True, diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py index 4149a97eaba..88dab19cd99 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py @@ -8,18 +8,25 @@ upload-file documents, and rejects unsupported or missing file cases. from __future__ import annotations import importlib +import json import sys from collections import UserDict +from datetime import UTC, datetime from inspect import unwrap from io import BytesIO -from types import SimpleNamespace -from unittest.mock import MagicMock from zipfile import ZipFile import pytest from flask import Flask +from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound +from extensions.storage.storage_type import StorageType +from models.account import Account, TenantAccountRole +from models.dataset import Dataset, Document +from models.enums import CreatorUserRole +from models.model import UploadFile + @pytest.fixture def app() -> Flask: @@ -67,31 +74,57 @@ def datasets_document_module(monkeypatch: pytest.MonkeyPatch): return importlib.import_module(module_name) -def _mock_user(*, is_dataset_editor: bool = True) -> SimpleNamespace: - """Build a minimal user object compatible with dataset permission checks.""" - return SimpleNamespace(is_dataset_editor=is_dataset_editor, id="user-123") +def _account(*, is_dataset_editor: bool = True) -> Account: + """Build a real account with the role used by dataset permission checks.""" + account = Account(name="Dataset User", email="dataset-user@example.com") + account.id = "user-123" + account.role = TenantAccountRole.EDITOR if is_dataset_editor else TenantAccountRole.NORMAL + return account -def _mock_document( +def _document( *, document_id: str, tenant_id: str, data_source_type: str, upload_file_id: str | None, -) -> SimpleNamespace: - """Build a minimal document object used by the controller.""" - data_source_info_dict: dict[str, object] | None = None - if upload_file_id is not None: - data_source_info_dict = {"upload_file_id": upload_file_id} - else: - data_source_info_dict = {} - - return SimpleNamespace( +) -> Document: + """Build a real document entity used by the controller.""" + document = Document( id=document_id, tenant_id=tenant_id, + dataset_id="ds-1", + position=1, data_source_type=data_source_type, - data_source_info_dict=data_source_info_dict, + data_source_info=json.dumps({"upload_file_id": upload_file_id}) if upload_file_id is not None else "{}", + batch="batch-1", + name="document.txt", + created_from="web", + created_by="user-123", ) + return document + + +def _dataset() -> Dataset: + return Dataset(id="ds-1", tenant_id="tenant-123", name="Dataset", created_by="user-123") + + +def _upload_file(*, file_id: str, name: str = "document.txt", key: str = "key") -> UploadFile: + upload_file = UploadFile( + tenant_id="tenant-123", + storage_type=StorageType.LOCAL, + key=key, + name=name, + size=1, + extension="txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user-123", + created_at=datetime.now(UTC), + used=False, + ) + upload_file.id = file_id + return upload_file def _wire_common_success_mocks( @@ -111,12 +144,12 @@ def _wire_common_success_mocks( monkeypatch.setattr( module.DatasetService, "get_dataset_for_tenant", - lambda *_args, **_kwargs: SimpleNamespace(id="ds-1", tenant_id="tenant-123"), + lambda *_args, **_kwargs: _dataset(), ) monkeypatch.setattr(module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None) # Return a document that will be validated inside DocumentResource.get_document. - document = _mock_document( + document = _document( document_id="doc-1", tenant_id=document_tenant_id, data_source_type=data_source_type, @@ -131,7 +164,7 @@ def _wire_common_success_mocks( # Mock UploadFile lookup via FileService batch helper. upload_files_by_id: dict[str, object] = {} if upload_file_exists and upload_file_id is not None: - upload_files_by_id[upload_file_id] = SimpleNamespace(id=upload_file_id) + upload_files_by_id[upload_file_id] = _upload_file(file_id=upload_file_id) monkeypatch.setattr(module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: upload_files_by_id) # Mock signing helper so the returned URL is deterministic. @@ -154,25 +187,23 @@ def _mock_send_file(obj, **kwargs): # type: ignore[no-untyped-def] def test_batch_download_zip_returns_send_file( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure batch ZIP download returns a zip attachment via `send_file`.""" - monkeypatch.setattr( - datasets_document_module.DatasetService, "get_dataset", lambda *_args, **_kwargs: SimpleNamespace(id="ds-1") - ) + monkeypatch.setattr(datasets_document_module.DatasetService, "get_dataset", lambda *_args, **_kwargs: _dataset()) monkeypatch.setattr( datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None ) # Two upload-file documents, each referencing an UploadFile. - doc1 = _mock_document( + doc1 = _document( document_id="11111111-1111-1111-1111-111111111111", tenant_id="tenant-123", data_source_type="upload_file", upload_file_id="file-1", ) - doc2 = _mock_document( + doc2 = _document( document_id="22222222-2222-2222-2222-222222222222", tenant_id="tenant-123", data_source_type="upload_file", @@ -187,8 +218,8 @@ def test_batch_download_zip_returns_send_file( datasets_document_module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: { - "file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"), - "file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"), + "file-1": _upload_file(file_id="file-1", name="a.txt", key="k1"), + "file-2": _upload_file(file_id="file-2", name="b.txt", key="k2"), }, ) @@ -208,7 +239,7 @@ def test_batch_download_zip_returns_send_file( ): api = datasets_document_module.DocumentBatchDownloadZipApi() method = unwrap(api.post) - result = method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1") + result = method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1") # Assert: we returned via send_file with correct mime type and attachment. assert result["_send_file_kwargs"]["mimetype"] == "application/zip" @@ -221,25 +252,23 @@ def test_batch_download_zip_returns_send_file( def test_batch_download_zip_response_is_openable_zip( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure the real Flask `send_file` response body is a valid ZIP that can be opened.""" # Arrange: same controller mocks as the lightweight send_file test, but we keep the real `send_file`. - monkeypatch.setattr( - datasets_document_module.DatasetService, "get_dataset", lambda *_args, **_kwargs: SimpleNamespace(id="ds-1") - ) + monkeypatch.setattr(datasets_document_module.DatasetService, "get_dataset", lambda *_args, **_kwargs: _dataset()) monkeypatch.setattr( datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None ) - doc1 = _mock_document( + doc1 = _document( document_id="33333333-3333-3333-3333-333333333333", tenant_id="tenant-123", data_source_type="upload_file", upload_file_id="file-1", ) - doc2 = _mock_document( + doc2 = _document( document_id="44444444-4444-4444-4444-444444444444", tenant_id="tenant-123", data_source_type="upload_file", @@ -254,8 +283,8 @@ def test_batch_download_zip_response_is_openable_zip( datasets_document_module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: { - "file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"), - "file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"), + "file-1": _upload_file(file_id="file-1", name="a.txt", key="k1"), + "file-2": _upload_file(file_id="file-2", name="b.txt", key="k2"), }, ) @@ -274,7 +303,7 @@ def test_batch_download_zip_response_is_openable_zip( ): api = datasets_document_module.DocumentBatchDownloadZipApi() method = unwrap(api.post) - response = method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1") + response = method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1") # Assert: response body is a valid ZIP and contains the expected entries. response.direct_passthrough = False @@ -288,18 +317,16 @@ def test_batch_download_zip_response_is_openable_zip( def test_batch_download_zip_rejects_non_upload_file_document( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure batch ZIP download rejects non upload-file documents.""" - monkeypatch.setattr( - datasets_document_module.DatasetService, "get_dataset", lambda *_args, **_kwargs: SimpleNamespace(id="ds-1") - ) + monkeypatch.setattr(datasets_document_module.DatasetService, "get_dataset", lambda *_args, **_kwargs: _dataset()) monkeypatch.setattr( datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None ) - doc = _mock_document( + doc = _document( document_id="55555555-5555-5555-5555-555555555555", tenant_id="tenant-123", data_source_type="website_crawl", @@ -319,11 +346,11 @@ def test_batch_download_zip_rejects_non_upload_file_document( api = datasets_document_module.DocumentBatchDownloadZipApi() method = unwrap(api.post) with pytest.raises(NotFound): - method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1") + method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1") def test_document_download_returns_url_for_upload_file_document( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure upload-file documents return a `{url}` JSON payload.""" @@ -341,13 +368,13 @@ def test_document_download_returns_url_for_upload_file_document( with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"): api = datasets_document_module.DocumentDownloadApi() method = unwrap(api.get) - result = method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1", document_id="doc-1") + result = method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1", document_id="doc-1") assert result == {"url": "https://example.com/signed"} def test_document_download_rejects_non_upload_file_document( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure non-upload documents raise 404 (no file to download).""" @@ -365,11 +392,11 @@ def test_document_download_rejects_non_upload_file_document( api = datasets_document_module.DocumentDownloadApi() method = unwrap(api.get) with pytest.raises(NotFound): - method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1", document_id="doc-1") + method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1", document_id="doc-1") def test_document_download_rejects_missing_upload_file_id( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure missing `upload_file_id` raises 404.""" @@ -387,11 +414,11 @@ def test_document_download_rejects_missing_upload_file_id( api = datasets_document_module.DocumentDownloadApi() method = unwrap(api.get) with pytest.raises(NotFound): - method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1", document_id="doc-1") + method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1", document_id="doc-1") def test_document_download_rejects_when_upload_file_record_missing( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure missing UploadFile row raises 404.""" @@ -409,11 +436,11 @@ def test_document_download_rejects_when_upload_file_record_missing( api = datasets_document_module.DocumentDownloadApi() method = unwrap(api.get) with pytest.raises(NotFound): - method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1", document_id="doc-1") + method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1", document_id="doc-1") def test_document_download_rejects_document_owner_mismatch( - app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: """Ensure an owner mismatch is rejected by the shared document resolver.""" @@ -431,4 +458,4 @@ def test_document_download_rejects_document_owner_mismatch( api = datasets_document_module.DocumentDownloadApi() method = unwrap(api.get) with pytest.raises(NotFound): - method(api, MagicMock(), "tenant-123", _mock_user(), dataset_id="ds-1", document_id="doc-1") + method(api, sqlite_session, "tenant-123", _account(), dataset_id="ds-1", document_id="doc-1") diff --git a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py index 002d6b61c3c..e66a461833b 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py @@ -133,11 +133,14 @@ def _controller_context( role: TenantAccountRole = TenantAccountRole.OWNER, auth_enabled: bool = False, ) -> Generator[None]: - features = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=auth_enabled)) with ( patch.object(module.db, "session", database_session), patch.object(module.TenantService, "get_user_role", return_value=role), - patch.object(service_module.FeatureService, "get_system_features", return_value=features), + patch.object( + service_module.SystemFeatureService, + "is_webapp_auth_enabled", + return_value=auth_enabled, + ), ): yield diff --git a/api/tests/unit_tests/controllers/console/explore/test_wraps.py b/api/tests/unit_tests/controllers/console/explore/test_wraps.py index fee341593d7..71027ae6d52 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/explore/test_wraps.py @@ -1,4 +1,3 @@ -from types import SimpleNamespace from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -140,16 +139,14 @@ def test_user_allowed_to_access_app_denied(): def view(installed_app): return "ok" - feature = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)) - with ( patch( "controllers.console.explore.wraps.current_account_with_tenant", return_value=(_account(account_id="user-1"), None), ), patch( - "controllers.console.explore.wraps.FeatureService.get_system_features", - return_value=feature, + "controllers.console.explore.wraps.SystemFeatureService.is_webapp_auth_enabled", + return_value=True, ), patch( "controllers.console.explore.wraps.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp", @@ -167,16 +164,14 @@ def test_user_allowed_to_access_app_success(): def view(installed_app): return "ok" - feature = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)) - with ( patch( "controllers.console.explore.wraps.current_account_with_tenant", return_value=(_account(account_id="user-1"), None), ), patch( - "controllers.console.explore.wraps.FeatureService.get_system_features", - return_value=feature, + "controllers.console.explore.wraps.SystemFeatureService.is_webapp_auth_enabled", + return_value=True, ), patch( "controllers.console.explore.wraps.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp", diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py index 5013ea58ffd..8955ddf6bf7 100644 --- a/api/tests/unit_tests/controllers/console/test_apikey.py +++ b/api/tests/unit_tests/controllers/console/test_apikey.py @@ -262,13 +262,6 @@ def test_api_key_lists_require_matching_rbac_permission(config_overrides: Callab lambda: AppApiKeyListResource().get(resource_id=api_id), [(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True)], ), - ( - lambda: AgentApiKeyListApi().get(agent_id=api_id), - [ - (RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, False), - (RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True), - ], - ), ( lambda: DatasetApiKeyApi().get(), [(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, False)], diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index a97f366da5b..5afc9fc1de4 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -1,6 +1,6 @@ from inspect import unwrap from types import SimpleNamespace -from unittest.mock import create_autospec +from unittest.mock import MagicMock, create_autospec from pytest_mock import MockerFixture @@ -27,7 +27,7 @@ def _request_context() -> RequestContext: ) -def _install_application_services(mocker: MockerFixture): +def _install_application_services(mocker: MockerFixture) -> MagicMock: feature_queries = create_autospec(FeatureQueryService, instance=True, spec_set=True) services = SimpleNamespace(feature_queries=feature_queries) mocker.patch("controllers.console.feature.application_services", return_value=services) @@ -35,7 +35,7 @@ def _install_application_services(mocker: MockerFixture): class TestFeatureApi: - def test_get_tenant_features_success(self, mocker: MockerFixture): + def test_get_tenant_features_success(self, mocker: MockerFixture) -> None: from controllers.console.feature import FeatureApi features = FeatureModel( @@ -59,7 +59,7 @@ class TestFeatureApi: class TestFeatureVectorSpaceApi: - def test_get_vector_space_success(self, mocker: MockerFixture): + def test_get_vector_space_success(self, mocker: MockerFixture) -> None: from controllers.console.feature import FeatureVectorSpaceApi feature_queries = _install_application_services(mocker) @@ -75,7 +75,7 @@ class TestFeatureVectorSpaceApi: assert result == {"size": 5120, "limit": 20480} get_vector_space.assert_called_once_with(request_context) - def test_get_vector_space_preserves_unknown_usage(self, mocker: MockerFixture): + def test_get_vector_space_preserves_unknown_usage(self, mocker: MockerFixture) -> None: from controllers.console.feature import FeatureVectorSpaceApi feature_queries = _install_application_services(mocker) @@ -88,7 +88,7 @@ class TestFeatureVectorSpaceApi: assert result == {"size": 0, "limit": 50, "usage_unknown": True} get_vector_space.assert_called_once_with(request_context) - def test_vector_space_response_schema_marks_usage_unknown_optional(self): + def test_vector_space_response_schema_marks_usage_unknown_optional(self) -> None: schema = VectorSpaceLimitationModel.model_json_schema(mode="serialization") assert schema["required"] == ["size", "limit"] @@ -97,7 +97,7 @@ class TestFeatureVectorSpaceApi: class TestTrialModelsApi: - def test_get_trial_models_success(self, mocker: MockerFixture): + def test_get_trial_models_success(self, mocker: MockerFixture) -> None: from controllers.console.feature import TrialModelsApi feature_queries = _install_application_services(mocker) @@ -115,7 +115,7 @@ class TestTrialModelsApi: class TestAppDslVersionApi: - def test_get_app_dsl_version_success(self, mocker: MockerFixture): + def test_get_app_dsl_version_success(self, mocker: MockerFixture) -> None: from controllers.console.feature import AppDslVersionApi feature_queries = _install_application_services(mocker) @@ -131,7 +131,7 @@ class TestAppDslVersionApi: class TestSystemFeatureApi: - def test_get_system_features_public(self, mocker: MockerFixture): + def test_get_system_features_public(self, mocker: MockerFixture) -> None: """The public endpoint returns system features without any authentication input.""" from controllers.console.feature import SystemFeatureApi @@ -142,7 +142,7 @@ class TestSystemFeatureApi: enable_learn_app=True, ) feature_queries = _install_application_services(mocker) - get_system_features = feature_queries.get_system_features + get_system_features = feature_queries.get_public_system_features get_system_features.return_value = system_features api = SystemFeatureApi() @@ -158,7 +158,7 @@ class TestSystemFeatureApi: class TestSystemFeatureLicenseApi: - def test_get_license_success(self, mocker: MockerFixture): + def test_get_license_success(self, mocker: MockerFixture) -> None: from controllers.console.feature import SystemFeatureLicenseApi license_model = LicenseModel( diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 20a7291ec66..aa3d966bdf7 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -14,6 +14,7 @@ from werkzeug.exceptions import HTTPException from controllers.common.wraps import _extract_resource_id from controllers.console import api as console_api from controllers.console import flask_admission +from controllers.console import wraps as wraps_module from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout from controllers.console.workspace.error import AccountNotInitializedError from controllers.console.wraps import ( @@ -54,6 +55,24 @@ def reset_setup_required_cache(): _is_setup_completed.reset_success() +@pytest.fixture(autouse=True) +def _application_services(monkeypatch: pytest.MonkeyPatch) -> None: + class FeatureQueries: + @staticmethod + def get_workspace_features(workspace_id: str): + return wraps_module.FeatureService.get_features(workspace_id, exclude_vector_space=True) + + @staticmethod + def get_workspace_vector_space(workspace_id: str): + return wraps_module.FeatureService.get_vector_space(workspace_id) + + monkeypatch.setattr( + wraps_module, + "application_services", + lambda: SimpleNamespace(feature_queries=FeatureQueries()), + ) + + @pytest.fixture(autouse=True) def _wraps_config(config_overrides: Callable[..., None]) -> None: config_overrides( @@ -197,15 +216,18 @@ class TestCurrentContextInjection: account_initialization_required.assert_called_once() def test_console_email_registration_admission_checks_features_once(self): - features = SimpleNamespace(enable_email_password_login=True, is_allow_register=True) with ( patch( "controllers.console.flask_admission.setup_required", side_effect=lambda view: view ) as setup_required, patch( - "controllers.console.flask_admission.FeatureService.get_system_features", - return_value=features, - ) as get_system_features, + "controllers.console.flask_admission.SystemFeatureService.is_email_password_login_enabled", + return_value=True, + ) as is_email_password_login_enabled, + patch( + "controllers.console.flask_admission.SystemFeatureService.is_registration_allowed", + return_value=True, + ) as is_registration_allowed, ): class Handler: @@ -218,7 +240,8 @@ class TestCurrentContextInjection: assert result == "ok" setup_required.assert_called_once() - get_system_features.assert_called_once_with() + is_email_password_login_enabled.assert_called_once_with() + is_registration_allowed.assert_called_once_with() @pytest.mark.parametrize( ("enable_email_password_login", "is_allow_register"), @@ -232,15 +255,15 @@ class TestCurrentContextInjection: enable_email_password_login: bool, is_allow_register: bool, ) -> None: - features = SimpleNamespace( - enable_email_password_login=enable_email_password_login, - is_allow_register=is_allow_register, - ) with ( patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), patch( - "controllers.console.flask_admission.FeatureService.get_system_features", - return_value=features, + "controllers.console.flask_admission.SystemFeatureService.is_email_password_login_enabled", + return_value=enable_email_password_login, + ), + patch( + "controllers.console.flask_admission.SystemFeatureService.is_registration_allowed", + return_value=is_allow_register, ), ): @@ -1231,16 +1254,16 @@ class TestEnterpriseLicense: def test_should_allow_with_valid_license(self): """Test that valid licenses allow access""" - # Arrange - mock_settings = MagicMock() - mock_settings.license.status = LicenseStatus.ACTIVE @enterprise_license_required def enterprise_feature(): return "enterprise_success" # Act - with patch("controllers.console.wraps.FeatureService.get_system_features", return_value=mock_settings): + with patch( + "controllers.console.wraps.SystemFeatureService.get_license_status", + return_value=LicenseStatus.ACTIVE, + ): result = enterprise_feature() # Assert @@ -1249,16 +1272,16 @@ class TestEnterpriseLicense: @pytest.mark.parametrize("invalid_status", [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]) def test_should_reject_with_invalid_license(self, invalid_status): """Test that invalid licenses raise UnauthorizedAndForceLogout""" - # Arrange - mock_settings = MagicMock() - mock_settings.license.status = invalid_status @enterprise_license_required def enterprise_feature(): return "enterprise_success" # Act & Assert - with patch("controllers.console.wraps.FeatureService.get_system_features", return_value=mock_settings): + with patch( + "controllers.console.wraps.SystemFeatureService.get_license_status", + return_value=invalid_status, + ): with pytest.raises(UnauthorizedAndForceLogout) as exc_info: enterprise_feature() assert "license is invalid" in str(exc_info.value) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_members.py b/api/tests/unit_tests/controllers/console/workspace/test_members.py index def8b13c0de..aedf0c12fc8 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_members.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py @@ -2,13 +2,13 @@ from contextlib import nullcontext from datetime import datetime from http import HTTPStatus from inspect import unwrap -from types import SimpleNamespace from typing import NamedTuple, override from unittest.mock import MagicMock, patch import pytest from flask import Flask from flask_restx import Resource +from sqlalchemy.orm import Session, scoped_session, sessionmaker from controllers.console.auth.error import ( CannotTransferOwnerToSelfError, @@ -34,6 +34,8 @@ from controllers.console.workspace.members import ( from enums import DeploymentEdition from libs.external_api import ExternalApi from machinery.context import RequestContext +from models.account import Account, Tenant, TenantAccountJoin +from models.engine import db from services.errors.account import AccountAlreadyInTenantError, SeatsLimitExceededError from services.workspace_member_query_service import ( WorkspaceMemberQueryService, @@ -57,6 +59,19 @@ class _ApplicationServicesStub(NamedTuple): workspace_member_queries: WorkspaceMemberQueryService +def _tenant(*, name: str = "Workspace") -> Tenant: + tenant = Tenant(name=name) + tenant.id = "t1" + return tenant + + +def _account(*, tenant: Tenant | None = None, account_id: str = "account-1", email: str = "a@test.com") -> Account: + account = Account(name="Test User", email=email) + account.id = account_id + account._current_tenant = tenant + return account + + class TestMemberListApi: def test_get_passes_context_and_serializes_application_result(self, app: Flask) -> None: api = MemberListApi() @@ -139,8 +154,8 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False features.workspace_members.is_available.return_value = True @@ -173,8 +188,8 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = True features.workspace_members.is_available.return_value = False @@ -197,8 +212,8 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.members.size = 9 features.members.limit = 10 @@ -222,8 +237,8 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False features.workspace_members.is_available.return_value = True @@ -259,7 +274,7 @@ class TestMemberInviteEmailApi: with app.test_request_context("/", json=payload): with pytest.raises(InvalidMemberRoleError) as exc_info: - method(api, MagicMock()) + method(api, _account()) assert exc_info.value.error_code == "invalid_role" @@ -267,11 +282,12 @@ class TestMemberInviteEmailApi: app = Flask(__name__) api = ExternalApi(app) method = unwrap(MemberInviteEmailApi.post) + current_user = _account() @api.route("/workspaces/current/members/invite-email") class MemberInviteValidationApi(Resource): def post(self): - return method(MemberInviteEmailApi(), MagicMock()) + return method(MemberInviteEmailApi(), current_user) response = app.test_client().post( "/workspaces/current/members/invite-email", @@ -286,8 +302,8 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False features.workspace_members.is_available.return_value = True @@ -315,12 +331,12 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = False + license_info = MagicMock() + license_info.seats.is_available.return_value = False payload = { "emails": ["a@test.com", "b@test.com"], @@ -332,16 +348,16 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 2)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.SystemFeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch("controllers.console.workspace.members.RegisterService.invite_new_member") as mock_invite, ): with pytest.raises(SeatsLimitExceeded): method(api, user) - mock_get_system_features.assert_called_once_with(is_authenticated=True) - system_features.license.seats.is_available.assert_called_once_with(2) + mock_get_license.assert_called_once_with() + license_info.seats.is_available.assert_called_once_with(2) mock_invite.assert_not_called() def test_invite_existing_accounts_do_not_consume_seats(self, app: Flask, config_overrides): @@ -349,12 +365,12 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = False + license_info = MagicMock() + license_info.seats.is_available.return_value = False payload = { "emails": ["a@test.com", "b@test.com"], @@ -366,9 +382,9 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 0)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.SystemFeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch( "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, @@ -377,8 +393,8 @@ class TestMemberInviteEmailApi: assert status == 201 assert len(result["invitation_results"]) == 2 - mock_get_system_features.assert_not_called() - system_features.license.seats.is_available.assert_not_called() + mock_get_license.assert_not_called() + license_info.seats.is_available.assert_not_called() assert mock_invite.call_count == 2 def test_invite_mixed_accounts_with_available_seats(self, app: Flask, config_overrides): @@ -386,12 +402,12 @@ class TestMemberInviteEmailApi: api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = True + license_info = MagicMock() + license_info.seats.is_available.return_value = True payload = { "emails": ["a@test.com", "b@test.com"], @@ -403,9 +419,9 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 1)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.SystemFeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch( "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, @@ -414,20 +430,20 @@ class TestMemberInviteEmailApi: assert status == 201 assert len(result["invitation_results"]) == 2 - mock_get_system_features.assert_called_once_with(is_authenticated=True) - system_features.license.seats.is_available.assert_called_once_with(1) + mock_get_license.assert_called_once_with() + license_info.seats.is_available.assert_called_once_with(1) assert mock_invite.call_count == 2 def test_invite_skips_seats_limit_when_enterprise_disabled(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = False + license_info = MagicMock() + license_info.seats.is_available.return_value = False payload = { "emails": ["a@test.com"], @@ -439,29 +455,29 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.SystemFeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"), ): result, status = method(api, user) assert status == 201 assert result["invitation_results"][0]["status"] == "success" - mock_get_system_features.assert_not_called() - system_features.license.seats.is_available.assert_not_called() + mock_get_license.assert_not_called() + license_info.seats.is_available.assert_not_called() def test_invite_seats_error_is_reported_as_failed_result(self, app: Flask, config_overrides): config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) api = MemberInviteEmailApi() method = unwrap(api.post) - tenant = MagicMock(id="t1") - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) features = MagicMock() features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = True + license_info = MagicMock() + license_info.seats.is_available.return_value = True payload = { "emails": ["a@test.com"], @@ -473,8 +489,8 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, + "controllers.console.workspace.members.SystemFeatureService.get_license", + return_value=license_info, ), patch( "controllers.console.workspace.members.RegisterService.invite_new_member", @@ -489,19 +505,30 @@ class TestMemberInviteEmailApi: class TestCountNewMemberInvites: - def test_count_new_member_invites(self): + def test_count_new_member_invites( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session] + ): new_account = None - existing_account_not_in_tenant = SimpleNamespace(id="account-2") - existing_account_in_tenant = SimpleNamespace(id="account-3") + existing_account_not_in_tenant = Account(name="External", email="existing@test.com") + existing_account_not_in_tenant.id = "account-2" + existing_account_in_tenant = Account(name="Member", email="member@test.com") + existing_account_in_tenant.id = "account-3" + database_session = scoped_session(sqlite_session_factory) + monkeypatch.setattr(db, "session", database_session) + database_session.add( + TenantAccountJoin( + tenant_id="tenant-1", + account_id="account-3", + current=True, + role="normal", + ) + ) + database_session.commit() - with ( - patch( - "controllers.console.workspace.members.AccountService.get_account_by_email_with_case_fallback", - side_effect=[new_account, existing_account_not_in_tenant, existing_account_in_tenant], - ) as mock_get_account, - patch("controllers.console.workspace.members.db.session") as mock_session, - ): - mock_session.scalar.side_effect = [None, "join-id"] + with patch( + "controllers.console.workspace.members.AccountService.get_account_by_email_with_case_fallback", + side_effect=[new_account, existing_account_not_in_tenant, existing_account_in_tenant], + ) as mock_get_account: result = _count_new_member_invites( "tenant-1", ["new@test.com", "existing@test.com", "member@test.com"], @@ -509,7 +536,7 @@ class TestCountNewMemberInvites: assert result == (2, 1) assert mock_get_account.call_count == 3 - assert mock_session.scalar.call_count == 2 + database_session.remove() class TestMemberUpdateRoleApi: @@ -520,7 +547,7 @@ class TestMemberUpdateRoleApi: payload = {"role": "invalid-role"} with app.test_request_context("/", json=payload): - result, status = method(api, MagicMock(), "id") + result, status = method(api, _account(), "id") assert status == 400 @@ -530,8 +557,8 @@ class TestDatasetOperatorMemberListApi: api = DatasetOperatorMemberListApi() method = unwrap(api.get) - tenant = MagicMock() - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) member = MagicMock() member.id = "op1" member.name = "Operator" @@ -556,7 +583,7 @@ class TestDatasetOperatorMemberListApi: api = DatasetOperatorMemberListApi() method = unwrap(api.get) - user = MagicMock(current_tenant=None) + user = _account(tenant=None) with ( app.test_request_context("/"), @@ -570,8 +597,8 @@ class TestSendOwnerTransferEmailApi: api = SendOwnerTransferEmailApi() method = unwrap(api.post) - tenant = MagicMock(name="ws") - user = MagicMock(email="a@test.com", current_tenant=tenant) + tenant = _tenant(name="ws") + user = _account(tenant=tenant, email="a@test.com") payload = {} @@ -600,14 +627,14 @@ class TestSendOwnerTransferEmailApi: patch("controllers.console.workspace.members.AccountService.is_email_send_ip_limit", return_value=True), ): with pytest.raises(EmailSendIpLimitError): - method(api, MagicMock()) + method(api, _account()) def test_send_not_owner(self, app: Flask): api = SendOwnerTransferEmailApi() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant) with ( app.test_request_context("/", json={}), @@ -624,8 +651,8 @@ class TestOwnerTransferCheckApi: api = OwnerTransferCheckApi() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(email="a@test.com", current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant, email="a@test.com") payload = {"code": "x", "token": "t"} @@ -648,8 +675,8 @@ class TestOwnerTransferCheckApi: api = OwnerTransferCheckApi() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(email="a@test.com", current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant, email="a@test.com") payload = {"code": "x", "token": "t"} @@ -668,8 +695,8 @@ class TestOwnerTransferCheckApi: api = OwnerTransferCheckApi() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(email="a@test.com", current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant, email="a@test.com") payload = {"code": "x", "token": "t"} @@ -689,8 +716,8 @@ class TestOwnerTransferCheckApi: api = OwnerTransferCheckApi() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(email="a@test.com", current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant, email="a@test.com") payload = {"code": "x", "token": "t"} @@ -715,8 +742,8 @@ class TestOwnerTransferApi: api = OwnerTransfer() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(id="1", email="a@test.com", current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant, account_id="1", email="a@test.com") payload = {"token": "t"} @@ -731,8 +758,8 @@ class TestOwnerTransferApi: api = OwnerTransfer() method = unwrap(api.post) - tenant = MagicMock() - user = MagicMock(id="1", email="a@test.com", current_tenant=tenant) + tenant = _tenant() + user = _account(tenant=tenant, account_id="1", email="a@test.com") payload = {"token": "t"} diff --git a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py index a4f14e5255d..2af0ce3db7a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py @@ -1,11 +1,13 @@ +from collections.abc import Callable from inspect import unwrap +from types import SimpleNamespace from unittest.mock import patch import pytest -from flask import Flask, g +from flask import Flask, g, request from pydantic_core import ValidationError from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import Forbidden, UnprocessableEntity from configs import dify_config from controllers.console.workspace.model_providers import ( @@ -17,6 +19,14 @@ from controllers.console.workspace.model_providers import ( ModelProviderPaymentCheckoutUrlApi, ModelProviderSummaryListApi, ModelProviderValidateApi, + ParserCredentialCreate, + ParserCredentialDelete, + ParserCredentialId, + ParserCredentialSwitch, + ParserCredentialUpdate, + ParserCredentialValidate, + ParserModelList, + ParserPreferredProviderType, PreferredProviderTypeUpdateApi, ) from core.entities.provider_entities import CredentialConfiguration @@ -26,6 +36,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.entities.provider_entities import ConfigurateMethod from graphon.model_runtime.errors.validate import CredentialsValidateFailedError from models import Account +from models.account import TenantAccountRole from models.provider import ProviderType from services.entities.model_provider_entities import ( CustomConfigurationResponse, @@ -116,6 +127,19 @@ def expected_provider_payload() -> dict[str, object]: } +def _payload() -> dict[str, object]: + """Mirror the source the ``@model_validate`` decorator reads for the request in scope. + + The tests build their contexts with ``test_request_context``, which defaults to GET even for + handlers mounted on POST, so fall back to the JSON body whenever the query string is empty. + """ + args: dict[str, object] = dict(request.args.to_dict(flat=True)) + if args: + return args + body = request.get_json(silent=True) + return dict(body) if isinstance(body, dict) else {} + + class TestModelProviderListApi: def test_get_success(self, app: Flask): api = ModelProviderListApi() @@ -129,7 +153,7 @@ class TestModelProviderListApi: return_value=[provider], ) as get_provider_list, ): - result = method(api, "tenant1") + result = method(api, ParserModelList.model_validate(_payload()), "tenant1") get_provider_list.assert_called_once_with(tenant_id="tenant1", model_type=ModelType.LLM) assert result == {"data": [expected_provider_payload()]} @@ -145,7 +169,7 @@ class TestModelProviderListApi: return_value=[], ) as get_provider_list, ): - result = method(api, "tenant1") + result = method(api, ParserModelList.model_validate(_payload()), "tenant1") get_provider_list.assert_called_once_with(tenant_id="tenant1", model_type=None) assert result == {"data": []} @@ -297,7 +321,7 @@ class TestModelProviderCredentialApi: }, ) as get_provider_credential, ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserCredentialId.model_validate(_payload()), "tenant1", provider="openai") get_provider_credential.assert_called_once_with( tenant_id="tenant1", provider="openai", credential_id=VALID_UUID @@ -321,7 +345,7 @@ class TestModelProviderCredentialApi: return_value=None, ) as get_provider_credential, ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserCredentialId.model_validate(_payload()), "tenant1", provider="openai") get_provider_credential.assert_called_once_with(tenant_id="tenant1", provider="openai", credential_id=None) assert result == {"credentials": None} @@ -332,7 +356,7 @@ class TestModelProviderCredentialApi: with app.test_request_context(f"/?credential_id={INVALID_UUID}"): with pytest.raises(ValidationError): - method(api, "tenant1", provider="openai") + method(api, ParserCredentialId.model_validate(_payload()), "tenant1", provider="openai") def test_post_create_success(self, app: Flask): api = ModelProviderCredentialApi() @@ -347,7 +371,9 @@ class TestModelProviderCredentialApi: return_value=None, ) as create_provider_credential, ): - result, status = method(api, "tenant1", provider="openai") + result, status = method( + api, ParserCredentialCreate.model_validate(_payload()), "tenant1", provider="openai" + ) create_provider_credential.assert_called_once_with( tenant_id="tenant1", @@ -372,7 +398,7 @@ class TestModelProviderCredentialApi: ), ): with pytest.raises(ValueError): - method(api, "tenant1", provider="openai") + method(api, ParserCredentialCreate.model_validate(_payload()), "tenant1", provider="openai") def test_put_update_success(self, app: Flask): api = ModelProviderCredentialApi() @@ -387,7 +413,7 @@ class TestModelProviderCredentialApi: return_value=None, ) as update_provider_credential, ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserCredentialUpdate.model_validate(_payload()), "tenant1", provider="openai") update_provider_credential.assert_called_once_with( tenant_id="tenant1", @@ -406,7 +432,7 @@ class TestModelProviderCredentialApi: with app.test_request_context("/", json=payload): with pytest.raises(ValidationError): - method(api, "tenant1", provider="openai") + method(api, ParserCredentialUpdate.model_validate(_payload()), "tenant1", provider="openai") def test_delete_success(self, app: Flask): api = ModelProviderCredentialApi() @@ -421,7 +447,9 @@ class TestModelProviderCredentialApi: return_value=None, ) as remove_provider_credential, ): - result, status = method(api, "tenant1", provider="openai") + result, status = method( + api, ParserCredentialDelete.model_validate(_payload()), "tenant1", provider="openai" + ) remove_provider_credential.assert_called_once_with( tenant_id="tenant1", provider="openai", credential_id=VALID_UUID @@ -444,7 +472,7 @@ class TestModelProviderCredentialSwitchApi: return_value=None, ) as switch_active_provider_credential, ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserCredentialSwitch.model_validate(_payload()), "tenant1", provider="openai") switch_active_provider_credential.assert_called_once_with( tenant_id="tenant1", @@ -461,7 +489,7 @@ class TestModelProviderCredentialSwitchApi: with app.test_request_context("/", json=payload): with pytest.raises(ValidationError): - method(api, "tenant1", provider="openai") + method(api, ParserCredentialSwitch.model_validate(_payload()), "tenant1", provider="openai") class TestModelProviderValidateApi: @@ -478,7 +506,7 @@ class TestModelProviderValidateApi: return_value=None, ) as validate_provider_credentials, ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserCredentialValidate.model_validate(_payload()), "tenant1", provider="openai") validate_provider_credentials.assert_called_once_with( tenant_id="tenant1", provider="openai", credentials={"a": "b"} @@ -498,7 +526,7 @@ class TestModelProviderValidateApi: side_effect=CredentialsValidateFailedError("bad"), ), ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserCredentialValidate.model_validate(_payload()), "tenant1", provider="openai") assert result == {"result": "error", "error": "bad"} @@ -549,7 +577,7 @@ class TestPreferredProviderTypeUpdateApi: return_value=None, ) as switch_preferred_provider, ): - result = method(api, "tenant1", provider="openai") + result = method(api, ParserPreferredProviderType.model_validate(_payload()), "tenant1", provider="openai") switch_preferred_provider.assert_called_once_with( tenant_id="tenant1", provider="openai", preferred_provider_type="custom" @@ -564,7 +592,7 @@ class TestPreferredProviderTypeUpdateApi: with app.test_request_context("/", json=payload): with pytest.raises(ValidationError): - method(api, "tenant1", provider="openai") + method(api, ParserPreferredProviderType.model_validate(_payload()), "tenant1", provider="openai") class TestModelProviderPaymentCheckoutUrlApi: @@ -618,3 +646,57 @@ class TestModelProviderPaymentCheckoutUrlApi: api.get(provider="anthropic") get_model_provider_payment_link.assert_not_called() + + +class TestModelValidateDecorator: + """The tests above unwrap the view, so this is what covers the decorators themselves.""" + + EMPTY_BODY: dict[str, object] = {} + EMPTY_KWARGS: dict[str, object] = {} + + @pytest.mark.parametrize( + ("verb", "url", "body", "call"), + [ + ("GET", "/?model_type=not-a-model-type", None, lambda: ModelProviderListApi().get()), + ( + "GET", + "/?credential_id=not-a-uuid", + None, + lambda: ModelProviderCredentialApi().get(provider="openai"), + ), + ("POST", "/", EMPTY_BODY, lambda: ModelProviderCredentialApi().post(provider="openai")), + ("PUT", "/", EMPTY_BODY, lambda: ModelProviderCredentialApi().put(provider="openai")), + ("DELETE", "/", EMPTY_BODY, lambda: ModelProviderCredentialApi().delete(provider="openai")), + ("POST", "/", EMPTY_BODY, lambda: ModelProviderCredentialSwitchApi().post(provider="openai")), + ("POST", "/", EMPTY_BODY, lambda: ModelProviderValidateApi().post(provider="openai")), + ("POST", "/", EMPTY_BODY, lambda: PreferredProviderTypeUpdateApi().post(provider="openai")), + ], + ) + def test_invalid_input_is_rejected_before_the_handler_runs( + self, + app: Flask, + verb: str, + url: str, + body: dict[str, object] | None, + call: Callable[[], object], + ) -> None: + account = make_account() + account.role = TenantAccountRole.OWNER + + with ( + app.test_request_context(url, method=verb, json=body), + config_overrides_context(LOGIN_DISABLED=True, RBAC_ENABLED=False), + patch("controllers.console.wraps._is_setup_completed", return_value=True), + patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(account, "tenant1"), + ), + patch("libs.login.current_user", SimpleNamespace(_get_current_object=lambda: account)), + patch("controllers.console.workspace.model_providers.ModelProviderService") as service, + ): + g._login_user = account + with pytest.raises(UnprocessableEntity) as exc_info: + call() + + assert exc_info.value.code == 422 + service.assert_not_called() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 6a7d95d0f0a..986816fea26 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -39,9 +39,12 @@ from controllers.console.workspace.workspace import ( WorkspacePermissionResponse, ) from enums import CloudPlan, DeploymentEdition +from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from machinery.context import RequestContext from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus +from models.enums import CreatorUserRole +from models.model import UploadFile from repositories.workspace_query_repository import WorkspaceQueryRepository from services import workspace_plan_gateway from services.workspace_query_service import WorkspaceQueryService, WorkspaceRecord @@ -304,7 +307,7 @@ class TestDeploymentWorkspacePlanGateway: class TestWorkspaceListApi: - def test_get_success(self, app: Flask): + def test_get_success(self, app: Flask, sqlite_session: Session): api = WorkspaceListApi() method = unwrap(api.get) tenant = make_tenant("t1", name="T") @@ -313,12 +316,12 @@ class TestWorkspaceListApi: app.test_request_context("/all-workspaces", query_string={"page": 1, "limit": 20}), patch("controllers.console.workspace.workspace.paginate_query", return_value=paginate_result), ): - result, status = method(api, MagicMock()) + result, status = method(api, sqlite_session) assert status == HTTPStatus.OK assert result["total"] == 1 assert result["has_more"] is False - def test_get_has_next_true(self, app: Flask): + def test_get_has_next_true(self, app: Flask, sqlite_session: Session): api = WorkspaceListApi() method = unwrap(api.get) tenant = make_tenant("t1", name="T") @@ -327,7 +330,7 @@ class TestWorkspaceListApi: app.test_request_context("/all-workspaces", query_string={"page": 1, "limit": 1}), patch("controllers.console.workspace.workspace.paginate_query", return_value=paginate_result), ): - result, status = method(api, MagicMock()) + result, status = method(api, sqlite_session) assert status == HTTPStatus.OK assert result["has_more"] is True @@ -340,12 +343,12 @@ def test_legacy_current_workspace_routes_are_not_registered(): class TestCurrentWorkspaceSummaryApi: - def test_get_summary(self, app: Flask): + def test_get_summary(self, app: Flask, sqlite_session: Session): api = CurrentWorkspaceSummaryApi() method = unwrap(api.get) tenant = make_tenant() user = make_account_with_tenant(tenant) - session = MagicMock() + session = sqlite_session summary = { "id": tenant.id, "name": tenant.name, @@ -373,7 +376,7 @@ class TestCurrentWorkspaceSummaryApi: } get_summary.assert_called_once_with(tenant, user.id, session=session) - def test_get_archived_tenant_returns_conflict(self, app: Flask): + def test_get_archived_tenant_returns_conflict(self, app: Flask, sqlite_session: Session): api = CurrentWorkspaceSummaryApi() method = unwrap(api.get) tenant = make_tenant(status=TenantStatus.ARCHIVE) @@ -381,7 +384,7 @@ class TestCurrentWorkspaceSummaryApi: with app.test_request_context("/workspaces/current/summary"): with pytest.raises(CurrentWorkspaceArchivedError) as exc_info: - method(api, MagicMock(), user) + method(api, sqlite_session, user) assert exc_info.value.code == HTTPStatus.CONFLICT assert exc_info.value.error_code == "current_workspace_archived" @@ -432,7 +435,7 @@ class TestSwitchWorkspaceApi: assert result["result"] == "success" switch_tenant.assert_called_once_with(user, "t2", session=workspace_session) - def test_switch_not_linked(self, app: Flask): + def test_switch_not_linked(self, app: Flask, sqlite_session: Session): api = SwitchWorkspaceApi() method = unwrap(api.post) payload = {"tenant_id": "bad"} @@ -442,7 +445,7 @@ class TestSwitchWorkspaceApi: patch("controllers.console.workspace.workspace.TenantService.switch_tenant", side_effect=Exception), ): with pytest.raises(AccountNotLinkTenantError): - method(api, MagicMock(), user) + method(api, sqlite_session, user) def test_switch_tenant_not_found(self, app: Flask, workspace_session: scoped_session[Session]): api = SwitchWorkspaceApi() @@ -570,8 +573,21 @@ class TestWebappLogoWorkspaceApi: api = WebappLogoWorkspaceApi() method = unwrap(api.post) file = FileStorage(stream=BytesIO(b"data"), filename="logo.png", content_type="image/png") - upload = MagicMock(id="file1") user = make_account() + upload = UploadFile( + tenant_id="t1", + storage_type=StorageType.LOCAL, + key="logo.png", + name="logo.png", + size=4, + extension="png", + mime_type="image/png", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=user.id, + created_at=naive_utc_now(), + used=False, + ) + upload.id = "file1" with ( app.test_request_context("/upload", data={"file": file}, content_type="multipart/form-data"), patch("controllers.console.workspace.workspace.FileService") as fs, @@ -651,13 +667,13 @@ class TestWorkspaceInfoApi: assert result["result"] == "success" assert events == ["commit", "get_tenant_info"] - def test_no_current_tenant(self, app: Flask): + def test_no_current_tenant(self, app: Flask, sqlite_session: Session): api = WorkspaceInfoApi() method = unwrap(api.post) payload = {"name": "X"} with app.test_request_context("/workspaces/info", json=payload): with pytest.raises(ValueError): - method(api, MagicMock(), None) + method(api, sqlite_session, None) class TestWorkspacePermissionApi: diff --git a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py index a7cf55008aa..5f798e05d06 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py @@ -11,7 +11,6 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import HTTPException from controllers.inner_api.wraps import ( - billing_inner_api_only, enterprise_inner_api_only, enterprise_inner_api_user_auth, inner_api_only, @@ -36,68 +35,6 @@ def _stable_uuid(value: str) -> str: return str(uuid5(NAMESPACE_URL, value)) -class TestBillingInnerApiOnly: - """Test billing_inner_api_only decorator""" - - def test_should_allow_when_inner_api_enabled_and_valid_key(self, app: Flask): - """Test that valid API key allows access when INNER_API is enabled""" - - # Arrange - @billing_inner_api_only - def protected_view(): - return "success" - - # Act - with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}): - result = protected_view() - - # Assert - assert result == "success" - - def test_should_return_404_when_inner_api_disabled(self, app: Flask, config_overrides): - """Test that 404 is returned when INNER_API is disabled""" - - # Arrange - @billing_inner_api_only - def protected_view(): - return "success" - - # Act & Assert - config_overrides(INNER_API=False) - with app.test_request_context(): - with pytest.raises(HTTPException) as exc_info: - protected_view() - assert exc_info.value.code == 404 - - def test_should_return_401_when_api_key_missing(self, app: Flask): - """Test that 401 is returned when X-Inner-Api-Key header is missing""" - - # Arrange - @billing_inner_api_only - def protected_view(): - return "success" - - # Act & Assert - with app.test_request_context(headers={}): - with pytest.raises(HTTPException) as exc_info: - protected_view() - assert exc_info.value.code == 401 - - def test_should_return_401_when_api_key_invalid(self, app: Flask): - """Test that 401 is returned when X-Inner-Api-Key header is invalid""" - - # Arrange - @billing_inner_api_only - def protected_view(): - return "success" - - # Act & Assert - with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}): - with pytest.raises(HTTPException) as exc_info: - protected_view() - assert exc_info.value.code == 401 - - class TestEnterpriseInnerApiOnly: """Test enterprise_inner_api_only decorator""" diff --git a/api/tests/unit_tests/controllers/inner_api/test_mail.py b/api/tests/unit_tests/controllers/inner_api/test_mail.py index c2ca35693eb..83bf0c6c471 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_mail.py +++ b/api/tests/unit_tests/controllers/inner_api/test_mail.py @@ -1,206 +1,130 @@ -""" -Unit tests for inner_api mail module -""" +"""Unit tests for the thin inner-mail Flask adapter and its admission boundary.""" -from unittest.mock import patch +from collections.abc import Callable +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import MagicMock, patch import pytest from flask import Flask from pydantic import ValidationError +from werkzeug.exceptions import NotFound -from controllers.inner_api.mail import ( - BaseMail, - BillingMail, - EnterpriseMail, - InnerMailPayload, -) +from controllers.inner_api.mail import BaseMail, BillingMail, EnterpriseMail, InnerMailPayload +from controllers.inner_api.wraps import InnerApiUnauthorizedError +from services.entities.mail_entities import InnerMailMessage class TestInnerMailPayload: - """Test InnerMailPayload Pydantic model""" - - def test_valid_payload_with_all_fields(self): - """Test valid payload with all fields passes validation""" - data = { - "to": ["test@example.com"], - "subject": "Test Subject", - "body": "Test Body", - "substitutions": {"key": "value"}, - } - payload = InnerMailPayload.model_validate(data) - assert payload.to == ["test@example.com"] - assert payload.subject == "Test Subject" - assert payload.body == "Test Body" - assert payload.substitutions == {"key": "value"} - - def test_valid_payload_without_substitutions(self): - """Test valid payload without optional substitutions""" - data = { - "to": ["test@example.com"], - "subject": "Test Subject", - "body": "Test Body", - } - payload = InnerMailPayload.model_validate(data) - assert payload.to == ["test@example.com"] - assert payload.subject == "Test Subject" - assert payload.body == "Test Body" - assert payload.substitutions is None - - def test_empty_to_list_fails_validation(self): - """Test that empty 'to' list fails validation due to min_length=1""" - data = { - "to": [], - "subject": "Test Subject", - "body": "Test Body", - } - with pytest.raises(ValidationError): - InnerMailPayload.model_validate(data) - - def test_multiple_recipients_allowed(self): - """Test that multiple recipients are allowed""" - data = { - "to": ["user1@example.com", "user2@example.com"], - "subject": "Test Subject", - "body": "Test Body", - } - payload = InnerMailPayload.model_validate(data) - assert len(payload.to) == 2 - assert "user1@example.com" in payload.to - assert "user2@example.com" in payload.to - - def test_missing_to_field_fails_validation(self): - """Test that missing 'to' field fails validation""" - data = { - "subject": "Test Subject", - "body": "Test Body", - } - with pytest.raises(ValidationError): - InnerMailPayload.model_validate(data) - - def test_missing_subject_fails_validation(self): - """Test that missing 'subject' field fails validation""" - data = { - "to": ["test@example.com"], - "body": "Test Body", - } - with pytest.raises(ValidationError): - InnerMailPayload.model_validate(data) - - def test_missing_body_fails_validation(self): - """Test that missing 'body' field fails validation""" - data = { - "to": ["test@example.com"], - "subject": "Test Subject", - } - with pytest.raises(ValidationError): - InnerMailPayload.model_validate(data) - - -class TestBaseMail: - """Test BaseMail API endpoint""" - - @pytest.fixture - def api_instance(self): - """Create BaseMail API instance""" - return BaseMail() - - @patch("controllers.inner_api.mail.send_inner_email_task") - def test_post_sends_email_task(self, mock_task, api_instance, app: Flask): - """Test that POST sends inner email task""" - # Arrange - mock_task.delay.return_value = None - - # Act - with app.test_request_context( - json={ + def test_valid_payload_with_all_fields(self) -> None: + payload = InnerMailPayload.model_validate( + { "to": ["test@example.com"], "subject": "Test Subject", "body": "Test Body", + "substitutions": {"key": "value"}, } - ): - with patch("controllers.inner_api.mail.inner_api_ns") as mock_ns: - mock_ns.payload = { - "to": ["test@example.com"], - "subject": "Test Subject", - "body": "Test Body", - } - result = api_instance.post() - - # Assert - assert result == ({"message": "success"}, 200) - mock_task.delay.assert_called_once_with( - to=["test@example.com"], - subject="Test Subject", - body="Test Body", - substitutions=None, ) + assert payload.to == ["test@example.com"] + assert payload.substitutions == {"key": "value"} - @patch("controllers.inner_api.mail.send_inner_email_task") - def test_post_with_substitutions(self, mock_task, api_instance, app: Flask): - """Test that POST sends email with substitutions""" - # Arrange - mock_task.delay.return_value = None + def test_valid_payload_without_substitutions(self) -> None: + payload = InnerMailPayload.model_validate( + {"to": ["test@example.com"], "subject": "Test Subject", "body": "Test Body"} + ) + assert payload.substitutions is None - # Act - with app.test_request_context(): - with patch("controllers.inner_api.mail.inner_api_ns") as mock_ns: - mock_ns.payload = { - "to": ["test@example.com"], + def test_valid_payload_with_null_substitutions(self) -> None: + payload = InnerMailPayload.model_validate( + { + "to": ["test@example.com"], + "subject": "Test Subject", + "body": "Test Body", + "substitutions": None, + } + ) + assert payload.substitutions is None + + @pytest.mark.parametrize( + "payload", + [ + {"to": [], "subject": "Subject", "body": "Body"}, + {"subject": "Subject", "body": "Body"}, + {"to": ["test@example.com"], "body": "Body"}, + {"to": ["test@example.com"], "subject": "Subject"}, + ], + ) + def test_invalid_payload(self, payload: dict[str, object]) -> None: + with pytest.raises(ValidationError): + InnerMailPayload.model_validate(payload) + + +class TestBaseMail: + @pytest.mark.parametrize( + ("resource_type", "payload", "expected"), + [ + ( + EnterpriseMail, + {"to": ["test@example.com"], "subject": "Subject", "body": "Body"}, + InnerMailMessage(recipients=("test@example.com",), subject="Subject", body="Body", substitutions=None), + ), + ( + BillingMail, + { + "to": ["one@example.com", "two@example.com"], "subject": "Hello {{name}}", "body": "Welcome {{name}}!", "substitutions": {"name": "John"}, - } - result = api_instance.post() + }, + InnerMailMessage( + recipients=("one@example.com", "two@example.com"), + subject="Hello {{name}}", + body="Welcome {{name}}!", + substitutions={"name": "John"}, + ), + ), + ], + ) + def test_post_delegates_to_application_service( + self, + resource_type: type[BaseMail], + payload: dict[str, object], + expected: InnerMailMessage, + app: Flask, + ) -> None: + mail_service = MagicMock() + services = SimpleNamespace(inner_mail=mail_service) + + with ( + app.test_request_context(), + patch("controllers.inner_api.mail.inner_api_ns") as namespace, + patch("controllers.inner_api.mail.application_services", return_value=services), + ): + namespace.payload = payload + result = unwrap(resource_type.post)(resource_type()) - # Assert assert result == ({"message": "success"}, 200) - mock_task.delay.assert_called_once_with( - to=["test@example.com"], - subject="Hello {{name}}", - body="Welcome {{name}}!", - substitutions={"name": "John"}, - ) + mail_service.send.assert_called_once_with(expected) -class TestEnterpriseMail: - """Test EnterpriseMail API endpoint""" - - @pytest.fixture - def api_instance(self): - """Create EnterpriseMail API instance""" - return EnterpriseMail() - - def test_has_enterprise_inner_api_only_decorator(self, api_instance): - """Test that EnterpriseMail has enterprise_inner_api_only decorator""" - # Check method_decorators - from controllers.inner_api.wraps import enterprise_inner_api_only - - assert enterprise_inner_api_only in api_instance.method_decorators - - def test_has_setup_required_decorator(self, api_instance): - """Test that EnterpriseMail has setup_required decorator""" - # Check by decorator name instead of object reference - decorator_names = [d.__name__ for d in api_instance.method_decorators] - assert "setup_required" in decorator_names +def test_disabled_inner_api_returns_not_found_before_setup(app: Flask, config_overrides: Callable[..., None]) -> None: + config_overrides(INNER_API=False) + with patch( + "controllers.console.wraps._is_setup_completed", + side_effect=AssertionError("setup must not run before Inner API authentication"), + ): + with app.test_request_context(), pytest.raises(NotFound): + EnterpriseMail().post() -class TestBillingMail: - """Test BillingMail API endpoint""" - - @pytest.fixture - def api_instance(self): - """Create BillingMail API instance""" - return BillingMail() - - def test_has_billing_inner_api_only_decorator(self, api_instance): - """Test that BillingMail has billing_inner_api_only decorator""" - # Check method_decorators - from controllers.inner_api.wraps import billing_inner_api_only - - assert billing_inner_api_only in api_instance.method_decorators - - def test_has_setup_required_decorator(self, api_instance): - """Test that BillingMail has setup_required decorator""" - # Check by decorator name instead of object reference - decorator_names = [d.__name__ for d in api_instance.method_decorators] - assert "setup_required" in decorator_names +def test_invalid_inner_api_key_is_rejected_before_setup(app: Flask, config_overrides: Callable[..., None]) -> None: + config_overrides(INNER_API=True, INNER_API_KEY="valid-key") + with patch( + "controllers.console.wraps._is_setup_completed", + side_effect=AssertionError("setup must not run before Inner API authentication"), + ): + with ( + app.test_request_context(headers={"X-Inner-Api-Key": "invalid-key"}), + pytest.raises(InnerApiUnauthorizedError), + ): + EnterpriseMail().post() diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py index e1b7a4f3cd3..a6629caabcf 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -145,7 +145,7 @@ def _selected_webapp_steps(*, scope, app_access_mode): Patches the config-backed conditions (edition + webapp_auth) so the gating reduces to PATH_HAS_APP_ID, LOADED_APP_IS_PRIVATE, and the request scope. """ - from unittest.mock import MagicMock, patch + from unittest.mock import patch from controllers.openapi.auth.data import AuthData @@ -160,12 +160,10 @@ def _selected_webapp_steps(*, scope, app_access_mode): scopes=frozenset({scope}) if scope is not None else frozenset(), app_access_mode=app_access_mode, ) - features = MagicMock() - features.webapp_auth.enabled = True selected = [] with ( config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), - patch("controllers.openapi.auth.conditions.FeatureService.get_system_features", return_value=features), + patch("controllers.openapi.auth.conditions.SystemFeatureService.is_webapp_auth_enabled", return_value=True), ): for step in account_pipeline._auth: if isinstance(step, When): diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py index a1f6b26c9ca..2d67cc84883 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from controllers.openapi.auth.conditions import ( EDITION_CLOUD, @@ -136,9 +136,7 @@ def test_edition_cloud(): def test_webapp_auth_enabled(): - mock_features = MagicMock() - mock_features.webapp_auth.enabled = True - with patch("controllers.openapi.auth.conditions.FeatureService.get_system_features", return_value=mock_features): + with patch("controllers.openapi.auth.conditions.SystemFeatureService.is_webapp_auth_enabled", return_value=True): assert WEBAPP_AUTH_ENABLED(_ctx()) is True diff --git a/api/tests/unit_tests/controllers/openapi/test_device_sso.py b/api/tests/unit_tests/controllers/openapi/test_device_sso.py index 38c5249bbc3..c59a50fb501 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_sso.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_sso.py @@ -1,7 +1,7 @@ """SSO-branch device-flow endpoints under /openapi/v1/oauth/device/.""" import builtins -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from flask import Flask @@ -145,12 +145,10 @@ def test_device_error_redirect_drops_malformed_user_code(): def _ee_features(): from services.entities.feature_entities import LicenseStatus - m = MagicMock() - m.license.status = LicenseStatus.ACTIVE - return m + return LicenseStatus.ACTIVE -@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.device_flow_security.SystemFeatureService.get_license_status") def test_sso_complete_relays_inbound_sso_error(ee_feat, openapi_app): ee_feat.return_value = _ee_features() client = openapi_app.test_client() @@ -165,7 +163,7 @@ def test_sso_complete_relays_inbound_sso_error(ee_feat, openapi_app): assert "user_code=ABCD-1234" in loc -@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.device_flow_security.SystemFeatureService.get_license_status") def test_sso_complete_missing_assertion_redirects_generic(ee_feat, openapi_app): ee_feat.return_value = _ee_features() client = openapi_app.test_client() diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py index 2d58c9499cc..59fdfc24d67 100644 --- a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from flask import Flask @@ -19,13 +19,11 @@ def app() -> Flask: def _ee_features(): from services.entities.feature_entities import LicenseStatus - m = MagicMock() - m.license.status = LicenseStatus.ACTIVE - return m + return LicenseStatus.ACTIVE @patch("controllers.openapi.oauth_device_sso.jws") -@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.device_flow_security.SystemFeatureService.get_license_status") def test_sso_complete_rejects_assertion_missing_email(ee_feat, jws_mod, app: Flask): ee_feat.return_value = _ee_features() jws_mod.verify.return_value = {"issuer": "https://idp.example", "user_code": "ABCD-EFGH", "nonce": "n"} @@ -40,7 +38,7 @@ def test_sso_complete_rejects_assertion_missing_email(ee_feat, jws_mod, app: Fla @patch("controllers.openapi.oauth_device_sso.jws") -@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.device_flow_security.SystemFeatureService.get_license_status") def test_sso_complete_rejects_assertion_empty_issuer(ee_feat, jws_mod, app: Flask): ee_feat.return_value = _ee_features() jws_mod.verify.return_value = {"email": "x@y.com", "issuer": "", "user_code": "ABCD-EFGH", "nonce": "n"} diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py index 6d0bb826ff4..2c9b1778927 100644 --- a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py @@ -20,15 +20,13 @@ def app() -> Flask: def _ee_features(): from services.entities.feature_entities import LicenseStatus - m = MagicMock() - m.license.status = LicenseStatus.ACTIVE - return m + return LicenseStatus.ACTIVE @patch("controllers.openapi.oauth_device_sso.EnterpriseService") @patch("controllers.openapi.oauth_device_sso.jws") @patch("controllers.openapi.oauth_device_sso.DeviceFlowRedis") -@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.device_flow_security.SystemFeatureService.get_license_status") @patch("libs.rate_limit.RateLimiter.is_rate_limited", new=MagicMock(return_value=False)) @patch("libs.rate_limit.RateLimiter.increment_rate_limit", new=MagicMock()) def test_idp_callback_url_uses_console_api_url_not_host_header( @@ -61,7 +59,7 @@ def test_idp_callback_url_uses_console_api_url_not_host_header( @patch("controllers.openapi.oauth_device_sso.DeviceFlowRedis") -@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.device_flow_security.SystemFeatureService.get_license_status") @patch("libs.rate_limit.RateLimiter.is_rate_limited", new=MagicMock(return_value=False)) @patch("libs.rate_limit.RateLimiter.increment_rate_limit", new=MagicMock()) def test_sso_initiate_fails_closed_when_console_api_url_unset(ee_feat, redis_cls, app: Flask, config_overrides): diff --git a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py index 4bd305008bc..b27c478ba6d 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py @@ -229,7 +229,8 @@ class TestAnnotationListApi: handler = unwrap(api.get) app_model = SimpleNamespace(id="app") with app.test_request_context("/apps/annotations", method="GET"): - response = handler(api, MagicMock(), app_model=app_model) + query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) + response = handler(api, query, MagicMock(), app_model=app_model) assert response["page"] == 1 assert response["limit"] == 20 session = get_mock.call_args.args[-1] @@ -244,7 +245,8 @@ class TestAnnotationListApi: handler = unwrap(api.get) app_model = SimpleNamespace(id="app") with app.test_request_context("/apps/annotations?page=2&limit=5&keyword=refund", method="GET"): - response = handler(api, MagicMock(), app_model=app_model) + query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) + response = handler(api, query, MagicMock(), app_model=app_model) assert response["total"] == 1 assert response["page"] == 2 assert response["limit"] == 5 @@ -259,11 +261,12 @@ class TestAnnotationListApi: get_mock = Mock(return_value=([], 0)) monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock) api = AnnotationListApi() - handler = unwrap(api.get) - app_model = SimpleNamespace(id="app") + # The parse moved into @model_validate, so build the query the way the decorator does and + # assert it is what rejects the input, before the view is ever reached. with app.test_request_context(f"/apps/annotations?{query_string}", method="GET"): with pytest.raises(ValidationError): - handler(api, MagicMock(), app_model=app_model) + AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) + assert unwrap(api.get) is not api.get get_mock.assert_not_called() def test_create(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 2c02a14ca58..94735d4e692 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -531,7 +531,12 @@ class TestConversationApiController: with app.test_request_context("/conversations", method="GET"): with pytest.raises(NotChatAppError): - handler(api, app_model=app_model, end_user=end_user) + handler( + api, + ConversationListQuery.model_validate(request.args.to_dict(flat=True)), + app_model=app_model, + end_user=end_user, + ) def test_list_last_not_found( self, @@ -558,7 +563,12 @@ class TestConversationApiController: method="GET", ): with pytest.raises(NotFound): - handler(api, app_model=app_model, end_user=end_user) + handler( + api, + ConversationListQuery.model_validate(request.args.to_dict(flat=True)), + app_model=app_model, + end_user=end_user, + ) class TestConversationDetailApiController: @@ -639,6 +649,7 @@ class TestConversationVariablesApiController: with pytest.raises(NotChatAppError): handler( api, + ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)), app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -663,6 +674,7 @@ class TestConversationVariablesApiController: with pytest.raises(NotFound): handler( api, + ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)), app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -700,6 +712,7 @@ class TestConversationVariablesApiController: ): result = handler( api, + ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)), app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_message.py b/api/tests/unit_tests/controllers/service_api/app/test_message.py index a1e1f699bbe..95c6a49b186 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_message.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_message.py @@ -415,9 +415,16 @@ class TestMessageListApi: app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) end_user = SimpleNamespace() - with app.test_request_context("/messages?conversation_id=cid", method="GET"): + # @model_validate parses ahead of the app-mode guard, so the id has to be well-formed to + # reach the branch this test is about. + with app.test_request_context("/messages?conversation_id=00000000-0000-0000-0000-000000000001", method="GET"): with pytest.raises(NotChatAppError): - handler(api, app_model=app_model, end_user=end_user) + handler( + api, + MessageListQuery.model_validate(request.args.to_dict(flat=True)), + app_model=app_model, + end_user=end_user, + ) def test_conversation_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( @@ -436,7 +443,12 @@ class TestMessageListApi: method="GET", ): with pytest.raises(NotFound): - handler(api, app_model=app_model, end_user=end_user) + handler( + api, + MessageListQuery.model_validate(request.args.to_dict(flat=True)), + app_model=app_model, + end_user=end_user, + ) def test_first_message_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( @@ -455,7 +467,12 @@ class TestMessageListApi: method="GET", ): with pytest.raises(NotFound): - handler(api, app_model=app_model, end_user=end_user) + handler( + api, + MessageListQuery.model_validate(request.args.to_dict(flat=True)), + app_model=app_model, + end_user=end_user, + ) class TestMessageFeedbackApi: @@ -503,7 +520,9 @@ class TestAppGetFeedbacksApi: app_model = SimpleNamespace() with app.test_request_context("/app/feedbacks?page=1&limit=20", method="GET"): - response = handler(api, app_model=app_model) + response = handler( + api, FeedbackListQuery.model_validate(request.args.to_dict(flat=True)), app_model=app_model + ) assert response == {"data": [feedback]} diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 82c03952c8d..322eac010c1 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -12,6 +12,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, scoped_session from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized +from controllers.service_api import wraps as wraps_module from controllers.service_api.wraps import ( DatasetApiResource, FetchUserArg, @@ -37,6 +38,20 @@ def _configure_current_app_mock(mock_current_app): mock_current_app._get_current_object = Mock(return_value=Mock()) +@pytest.fixture(autouse=True) +def _application_services(monkeypatch: pytest.MonkeyPatch) -> None: + class FeatureQueries: + @staticmethod + def get_workspace_vector_space(workspace_id: str): + return wraps_module.FeatureService.get_vector_space(workspace_id) + + monkeypatch.setattr( + wraps_module, + "application_services", + lambda: SimpleNamespace(feature_queries=FeatureQueries()), + ) + + def _session_proxy(session: Session) -> scoped_session[Session]: """Expose the real SQLite session through Flask-SQLAlchemy's callable shape.""" return scoped_session(lambda: session) diff --git a/api/tests/unit_tests/controllers/web/test_feature.py b/api/tests/unit_tests/controllers/web/test_feature.py index 68b8e72be71..1a08908bc25 100644 --- a/api/tests/unit_tests/controllers/web/test_feature.py +++ b/api/tests/unit_tests/controllers/web/test_feature.py @@ -31,7 +31,7 @@ class TestSystemFeatureApi: ) -> None: system_features = SystemFeatureModel(deployment_edition=deployment_edition) feature_queries = _install_feature_queries(mocker) - feature_queries.get_system_features.return_value = system_features + feature_queries.get_public_system_features.return_value = system_features with app.test_request_context("/system-features"): result = SystemFeatureApi().get() @@ -40,7 +40,7 @@ class TestSystemFeatureApi: assert result["deployment_edition"] == deployment_edition.value assert result["sso_enforced_for_signin_protocol"] is None assert result["webapp_auth"]["sso_config"]["protocol"] is None - feature_queries.get_system_features.assert_called_once_with() + feature_queries.get_public_system_features.assert_called_once_with() def test_unauthenticated_access(self) -> None: """SystemFeatureApi is unauthenticated by design — no WebApiResource decorator.""" diff --git a/api/tests/unit_tests/controllers/web/test_passport.py b/api/tests/unit_tests/controllers/web/test_passport.py deleted file mode 100644 index 4b099deeac3..00000000000 --- a/api/tests/unit_tests/controllers/web/test_passport.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -from unittest.mock import patch -from uuid import NAMESPACE_URL, UUID, uuid5 - -import pytest -from sqlalchemy import select -from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound, Unauthorized - -from controllers.web.error import WebAppAuthRequiredError -from controllers.web.passport import ( - PassportService, - decode_enterprise_webapp_user_id, - exchange_token_for_existing_web_user, - generate_session_id, -) -from models.enums import CustomizeTokenStrategy, EndUserType -from models.model import App, AppMode, EndUser, IconType, Site -from services.webapp_auth_service import WebAppAuthType - - -def _stable_uuid(value: str) -> str: - return str(uuid5(NAMESPACE_URL, value)) - - -def _persist_webapp(session: Session, *, app_code: str = "code") -> tuple[App, Site]: - tenant_id = _stable_uuid(f"tenant:{app_code}") - app_model = App( - id=_stable_uuid(f"app:{app_code}"), - tenant_id=tenant_id, - name="Web App", - mode=AppMode.CHAT, - icon_type=IconType.EMOJI, - icon="chat", - icon_background="#FFFFFF", - enable_site=True, - enable_api=False, - ) - site = Site( - id=_stable_uuid(f"site:{app_code}"), - app_id=app_model.id, - title="Web App Site", - default_language="en-US", - customize_token_strategy=CustomizeTokenStrategy.UUID, - code=app_code, - ) - session.add_all([app_model, site]) - session.commit() - return app_model, site - - -def test_decode_enterprise_webapp_user_id_none() -> None: - assert decode_enterprise_webapp_user_id(None) is None - - -def test_decode_enterprise_webapp_user_id_invalid_source(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(PassportService, "verify", lambda *_args, **_kwargs: {"token_source": "bad"}) - with pytest.raises(Unauthorized): - decode_enterprise_webapp_user_id("token") - - -def test_decode_enterprise_webapp_user_id_valid(monkeypatch: pytest.MonkeyPatch) -> None: - decoded = {"token_source": "webapp_login_token", "user_id": "u1"} - monkeypatch.setattr(PassportService, "verify", lambda *_args, **_kwargs: decoded) - assert decode_enterprise_webapp_user_id("token") == decoded - - -@pytest.mark.parametrize("sqlite_session", [(App, Site)], indirect=True) -def test_exchange_token_public_flow(sqlite_session: Session) -> None: - app_model, site = _persist_webapp(sqlite_session) - - decoded = {"auth_type": "public"} - with ( - patch("controllers.web.passport.db.session", sqlite_session), - patch("controllers.web.passport._exchange_for_public_app_token", return_value="resp") as exchange_mock, - ): - result = exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.PUBLIC) - - assert result == "resp" - exchange_mock.assert_called_once_with(app_model, site, decoded) - - -@pytest.mark.parametrize("sqlite_session", [(App, Site)], indirect=True) -def test_exchange_token_requires_external(sqlite_session: Session) -> None: - _persist_webapp(sqlite_session) - - decoded = {"auth_type": "internal"} - with ( - patch("controllers.web.passport.db.session", sqlite_session), - pytest.raises(WebAppAuthRequiredError), - ): - exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.EXTERNAL) - - -@pytest.mark.parametrize("sqlite_session", [(App, Site, EndUser)], indirect=True) -def test_exchange_token_missing_session_id(sqlite_session: Session) -> None: - _persist_webapp(sqlite_session) - - decoded = {"auth_type": "internal"} - with ( - patch("controllers.web.passport.db.session", sqlite_session), - pytest.raises(NotFound), - ): - exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.INTERNAL) - assert sqlite_session.scalars(select(EndUser)).all() == [] - - -@pytest.mark.parametrize("sqlite_session", [(EndUser,)], indirect=True) -def test_generate_session_id(sqlite_session: Session) -> None: - collision_id = _stable_uuid("session:collision") - generated_id = _stable_uuid("session:generated") - sqlite_session.add( - EndUser( - id=_stable_uuid("end-user:collision"), - tenant_id=_stable_uuid("tenant:collision"), - type=EndUserType.BROWSER, - name="Existing User", - session_id=collision_id, - ) - ) - sqlite_session.commit() - - with ( - patch("controllers.web.passport.db.session", sqlite_session), - patch( - "controllers.web.passport.uuid.uuid4", - side_effect=[UUID(collision_id), UUID(generated_id)], - ), - ): - session_id = generate_session_id() - - assert session_id == generated_id diff --git a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py index f43bf1fbf0a..5fde28d31cf 100644 --- a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py @@ -41,7 +41,10 @@ def _patch_wraps(): with ( patch("controllers.console.wraps.db") as mock_db, config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), + patch( + "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", + return_value=wraps_features.enable_email_password_login, + ), ): yield diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index d0e1133fa83..54079b96c80 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -55,7 +55,10 @@ def _patch_wraps( monkeypatch.setattr(console_wraps.db, "session", session_registry) with ( patch("controllers.console.wraps.dify_config", console_dify), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), + patch( + "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", + return_value=wraps_features.enable_email_password_login, + ), patch("controllers.web.login.dify_config", web_dify), ): yield diff --git a/api/tests/unit_tests/controllers/web/test_web_passport.py b/api/tests/unit_tests/controllers/web/test_web_passport.py index 82ec7f1bd44..9ac0b731a3a 100644 --- a/api/tests/unit_tests/controllers/web/test_web_passport.py +++ b/api/tests/unit_tests/controllers/web/test_web_passport.py @@ -1,240 +1,69 @@ -"""Unit tests for controllers.web.passport — token issuance and enterprise auth exchange.""" +"""Unit tests for the thin web-passport Flask adapter.""" -from __future__ import annotations - -import uuid +from inspect import unwrap from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from flask import Flask -from sqlalchemy import Engine, select -from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound, Unauthorized from controllers.web.error import WebAppAuthRequiredError -from controllers.web.passport import ( - PassportResource, - decode_enterprise_webapp_user_id, - exchange_token_for_existing_web_user, - generate_session_id, +from controllers.web.passport import PassportResource +from services.entities.passport_entities import WebPassportRequest, WebPassportResult +from services.web_passport_service import ( + WebPassportAuthenticationRequiredError, + WebPassportNotFoundError, + WebPassportUnauthorizedError, ) -from models.base import TypeBase -from models.enums import CustomizeTokenStrategy, EndUserType -from models.model import App, AppMode, EndUser, IconType, Site -from services.webapp_auth_service import WebAppAuthType -@pytest.fixture -def database_session(sqlite_engine: Engine): - models = (App, Site, EndUser) - tables = [model.metadata.tables[model.__tablename__] for model in models] - TypeBase.metadata.create_all(sqlite_engine, tables=tables) - with Session(sqlite_engine, expire_on_commit=False) as session: - with patch("controllers.web.passport.db.session", session): - yield session +def test_passport_resource_parses_input_and_serializes_result(app: Flask) -> None: + service = MagicMock() + service.issue.return_value = WebPassportResult(access_token="issued-token") + services = SimpleNamespace(web_passport=service) + with ( + app.test_request_context( + "/passport?user_id=session-1", + headers={"X-App-Code": "app-code", "Authorization": "Bearer login-token"}, + ), + patch("controllers.web.passport.application_services", return_value=services), + patch("controllers.web.passport.extract_webapp_access_token", return_value="login-token"), + ): + result = unwrap(PassportResource.get)(PassportResource()) -def _persist_webapp( - session: Session, - *, - app_code: str = "code1", - enable_site: bool = True, -) -> tuple[App, Site]: - app_model = App( - id=str(uuid.uuid4()), - tenant_id=str(uuid.uuid4()), - name="Web App", - mode=AppMode.CHAT, - icon_type=IconType.EMOJI, - icon="chat", - icon_background="#FFFFFF", - enable_site=enable_site, - enable_api=False, - ) - site = Site( - app_id=app_model.id, - title="Web App Site", - default_language="en-US", - customize_token_strategy=CustomizeTokenStrategy.UUID, - code=app_code, - ) - session.add_all([app_model, site]) - session.commit() - return app_model, site - - -def _end_user(app_model: App, *, session_id: str) -> EndUser: - return EndUser( - id=str(uuid.uuid4()), - tenant_id=app_model.tenant_id, - app_id=app_model.id, - type=EndUserType.BROWSER, - name="Web User", - session_id=session_id, + assert result == {"access_token": "issued-token"} + service.issue.assert_called_once_with( + WebPassportRequest(app_code="app-code", user_session_id="session-1", access_token="login-token") ) -# --------------------------------------------------------------------------- -# decode_enterprise_webapp_user_id -# --------------------------------------------------------------------------- -class TestDecodeEnterpriseWebappUserId: - def test_none_token_returns_none(self) -> None: - assert decode_enterprise_webapp_user_id(None) is None - - @patch("controllers.web.passport.PassportService") - def test_valid_token_returns_decoded(self, mock_passport_cls: MagicMock) -> None: - mock_passport_cls.return_value.verify.return_value = { - "token_source": "webapp_login_token", - "user_id": "u1", - } - result = decode_enterprise_webapp_user_id("valid-jwt") - assert result is not None - assert result["user_id"] == "u1" - - @patch("controllers.web.passport.PassportService") - def test_wrong_source_raises_unauthorized(self, mock_passport_cls: MagicMock) -> None: - mock_passport_cls.return_value.verify.return_value = { - "token_source": "other_source", - } - with pytest.raises(Unauthorized, match="Expected 'webapp_login_token'"): - decode_enterprise_webapp_user_id("bad-jwt") - - @patch("controllers.web.passport.PassportService") - def test_missing_source_raises_unauthorized(self, mock_passport_cls: MagicMock) -> None: - mock_passport_cls.return_value.verify.return_value = {} - with pytest.raises(Unauthorized, match="Expected 'webapp_login_token'"): - decode_enterprise_webapp_user_id("no-source-jwt") +def test_passport_resource_requires_app_code(app: Flask) -> None: + with app.test_request_context("/passport"), pytest.raises(Unauthorized, match="X-App-Code"): + unwrap(PassportResource.get)(PassportResource()) -# --------------------------------------------------------------------------- -# generate_session_id -# --------------------------------------------------------------------------- -class TestGenerateSessionId: - def test_returns_unique_session_id(self, database_session: Session) -> None: - sid = generate_session_id() - assert isinstance(sid, str) - assert len(sid) == 36 # UUID format +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + (WebPassportAuthenticationRequiredError("login required"), WebAppAuthRequiredError), + (WebPassportUnauthorizedError("bad token"), Unauthorized), + (WebPassportNotFoundError(), NotFound), + ], +) +def test_passport_resource_translates_application_errors( + service_error: Exception, + http_error: type[Exception], + app: Flask, +) -> None: + service = MagicMock() + service.issue.side_effect = service_error + services = SimpleNamespace(web_passport=service) - def test_retries_on_collision(self, database_session: Session) -> None: - app_model, _ = _persist_webapp(database_session) - collision_id = str(uuid.uuid4()) - generated_id = str(uuid.uuid4()) - database_session.add(_end_user(app_model, session_id=collision_id)) - database_session.commit() - - with patch( - "controllers.web.passport.uuid.uuid4", - side_effect=[uuid.UUID(collision_id), uuid.UUID(generated_id)], - ): - sid = generate_session_id() - - assert sid == generated_id - - -# --------------------------------------------------------------------------- -# exchange_token_for_existing_web_user -# --------------------------------------------------------------------------- -class TestExchangeTokenForExistingWebUser: - def test_external_auth_type_mismatch_raises(self, database_session: Session) -> None: - _persist_webapp(database_session) - decoded = {"user_id": "u1", "auth_type": "internal"} # mismatch: expected "external" - with pytest.raises(WebAppAuthRequiredError, match="external"): - exchange_token_for_existing_web_user( - app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.EXTERNAL - ) - - def test_internal_auth_type_mismatch_raises(self, database_session: Session) -> None: - _persist_webapp(database_session) - decoded = {"user_id": "u1", "auth_type": "external"} # mismatch: expected "internal" - with pytest.raises(WebAppAuthRequiredError, match="internal"): - exchange_token_for_existing_web_user( - app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.INTERNAL - ) - - def test_site_not_found_raises(self, database_session: Session) -> None: - decoded = {"user_id": "u1", "auth_type": "external"} - with pytest.raises(NotFound): - exchange_token_for_existing_web_user( - app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.EXTERNAL - ) - - -# --------------------------------------------------------------------------- -# PassportResource.get -# --------------------------------------------------------------------------- -class TestPassportResource: - @patch("controllers.web.passport.FeatureService.get_system_features") - def test_missing_app_code_raises_unauthorized(self, mock_features: MagicMock, app: Flask) -> None: - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) - with app.test_request_context("/passport"): - with pytest.raises(Unauthorized, match="X-App-Code"): - PassportResource().get() - - @patch("controllers.web.passport.PassportService") - @patch("controllers.web.passport.generate_session_id", return_value="new-sess-id") - @patch("controllers.web.passport.FeatureService.get_system_features") - def test_creates_new_end_user_when_no_user_id( - self, - mock_features: MagicMock, - mock_gen_session: MagicMock, - mock_passport_cls: MagicMock, - app: Flask, - database_session: Session, - sqlite_engine: Engine, - ) -> None: - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) - app_model, _ = _persist_webapp(database_session) - mock_passport_cls.return_value.issue.return_value = "issued-token" - - with app.test_request_context("/passport", headers={"X-App-Code": "code1"}): - response = PassportResource().get() - - assert response["access_token"] == "issued-token" - database_session.close() - with Session(sqlite_engine) as verification_session: - end_users = verification_session.scalars(select(EndUser)).all() - assert len(end_users) == 1 - assert end_users[0].session_id == "new-sess-id" - assert end_users[0].app_id == app_model.id - assert end_users[0].tenant_id == app_model.tenant_id - - @patch("controllers.web.passport.PassportService") - @patch("controllers.web.passport.FeatureService.get_system_features") - def test_reuses_existing_end_user_when_user_id_provided( - self, - mock_features: MagicMock, - mock_passport_cls: MagicMock, - app: Flask, - database_session: Session, - ) -> None: - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) - app_model, _ = _persist_webapp(database_session) - existing_user = _end_user(app_model, session_id="sess-existing") - database_session.add(existing_user) - database_session.commit() - mock_passport_cls.return_value.issue.return_value = "reused-token" - - with app.test_request_context("/passport?user_id=sess-existing", headers={"X-App-Code": "code1"}): - response = PassportResource().get() - - assert response["access_token"] == "reused-token" - end_users = database_session.scalars(select(EndUser)).all() - assert [end_user.id for end_user in end_users] == [existing_user.id] - - @patch("controllers.web.passport.FeatureService.get_system_features") - def test_site_not_found_raises(self, mock_features: MagicMock, app: Flask, database_session: Session) -> None: - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) - with app.test_request_context("/passport", headers={"X-App-Code": "code1"}): - with pytest.raises(NotFound): - PassportResource().get() - - @patch("controllers.web.passport.FeatureService.get_system_features") - def test_disabled_app_raises_not_found( - self, mock_features: MagicMock, app: Flask, database_session: Session - ) -> None: - mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)) - _persist_webapp(database_session, enable_site=False) - with app.test_request_context("/passport", headers={"X-App-Code": "code1"}): - with pytest.raises(NotFound): - PassportResource().get() + with ( + app.test_request_context("/passport", headers={"X-App-Code": "app-code"}), + patch("controllers.web.passport.application_services", return_value=services), + pytest.raises(http_error), + ): + unwrap(PassportResource.get)(PassportResource()) diff --git a/api/tests/unit_tests/controllers/web/test_wraps.py b/api/tests/unit_tests/controllers/web/test_wraps.py index 10ad77f744f..b3c35d70aa3 100644 --- a/api/tests/unit_tests/controllers/web/test_wraps.py +++ b/api/tests/unit_tests/controllers/web/test_wraps.py @@ -1,4 +1,3 @@ -from types import SimpleNamespace from unittest import mock from uuid import uuid4 @@ -90,9 +89,9 @@ def test_decode_jwt_token_uses_shared_session_factory(sqlite_session: Session) - mock.patch.object(wraps, "extract_webapp_passport", return_value="jwt-token"), mock.patch.object(wraps, "PassportService") as mock_passport_service, mock.patch.object( - wraps, - "FeatureService", - get_system_features=mock.Mock(return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))), + wraps.SystemFeatureService, + "is_webapp_auth_enabled", + return_value=False, ), ): mock_passport_service.return_value.verify.return_value = { diff --git a/api/tests/unit_tests/core/helper/test_credential_utils.py b/api/tests/unit_tests/core/helper/test_credential_utils.py index 8c32b13865a..1921ed99ae8 100644 --- a/api/tests/unit_tests/core/helper/test_credential_utils.py +++ b/api/tests/unit_tests/core/helper/test_credential_utils.py @@ -50,7 +50,7 @@ def test_check_credential_policy_compliance_returns_when_feature_disabled( mocker: MockerFixture, ) -> None: mocker.patch( - "services.feature_service.FeatureService.is_plugin_manager_enabled", + "services.system_feature_service.SystemFeatureService.is_plugin_manager_enabled", return_value=False, ) check_call = mocker.patch( @@ -66,7 +66,7 @@ def test_check_credential_policy_compliance_raises_when_credential_missing( mocker: MockerFixture, ) -> None: mocker.patch( - "services.feature_service.FeatureService.is_plugin_manager_enabled", + "services.system_feature_service.SystemFeatureService.is_plugin_manager_enabled", return_value=True, ) mocker.patch("core.helper.credential_utils.is_credential_exists", return_value=False) @@ -79,7 +79,7 @@ def test_check_credential_policy_compliance_calls_plugin_manager_with_request( mocker: MockerFixture, ) -> None: mocker.patch( - "services.feature_service.FeatureService.is_plugin_manager_enabled", + "services.system_feature_service.SystemFeatureService.is_plugin_manager_enabled", return_value=True, ) mocker.patch("core.helper.credential_utils.is_credential_exists", return_value=True) @@ -100,7 +100,7 @@ def test_check_credential_policy_compliance_skips_existence_check_when_disabled( mocker: MockerFixture, ) -> None: mocker.patch( - "services.feature_service.FeatureService.is_plugin_manager_enabled", + "services.system_feature_service.SystemFeatureService.is_plugin_manager_enabled", return_value=True, ) exists_call = mocker.patch("core.helper.credential_utils.is_credential_exists") @@ -123,7 +123,7 @@ def test_check_credential_policy_compliance_returns_when_credential_id_empty( mocker: MockerFixture, ) -> None: mocker.patch( - "services.feature_service.FeatureService.is_plugin_manager_enabled", + "services.system_feature_service.SystemFeatureService.is_plugin_manager_enabled", return_value=True, ) exists_call = mocker.patch("core.helper.credential_utils.is_credential_exists") diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index deaad7906db..90e28a91c32 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -1,13 +1,16 @@ import threading from collections.abc import Generator from contextlib import contextmanager, nullcontext +from datetime import datetime from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock, Mock, patch from uuid import uuid4 import pytest from flask import Flask, current_app +from sqlalchemy import select +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from core.app.app_config.entities import ( @@ -35,9 +38,20 @@ from core.rag.retrieval.dataset_retrieval import DatasetRetrieval from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.workflow.nodes.knowledge_retrieval import exc from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest +from extensions.storage.storage_type import StorageType from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.model_runtime.entities.model_entities import ModelFeature -from models.dataset import Dataset +from models import UploadFile +from models.dataset import ( + ChildChunk, + Dataset, + DatasetMetadata, + DatasetQuery, + DocumentSegment, + RateLimitLog, + SegmentAttachmentBinding, +) +from models.dataset import Document as DatasetDocument from models.enums import CreatorUserRole # ==================== Helper Functions ==================== @@ -89,23 +103,13 @@ def create_mock_document( def _dataset(**values: object) -> Dataset: - return cast(Dataset, SimpleNamespace(**values)) + return Dataset(**values) def _metadata_condition() -> AppMetadataFilteringCondition: return AppMetadataFilteringCondition(logical_operator="and", conditions=[]) -@contextmanager -def _patched_retriever_session(): - session = MagicMock() - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = None - with patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session", return_value=session_ctx): - yield session - - def create_side_effect_for_search(documents: list[Document]): """ Create a side effect function for mocking search methods. @@ -329,16 +333,9 @@ class TestRetrievalService: return app @pytest.fixture - def retrieval_session(self): - session = MagicMock() - session_context = MagicMock() - session_context.__enter__.return_value = session - session_context.__exit__.return_value = None - with ( - patch("core.rag.datasource.retrieval_service.db", SimpleNamespace(engine=Mock())), - patch("core.rag.datasource.retrieval_service.Session", return_value=session_context), - ): - yield session + def retrieval_engine(self, sqlite_engine: Engine): + with patch("core.rag.datasource.retrieval_service.db", SimpleNamespace(engine=sqlite_engine)): + yield sqlite_engine @pytest.fixture(autouse=True) def mock_thread_pool(self): @@ -723,7 +720,7 @@ class TestRetrievalService: mock_data_processor_class, mock_dataset, sample_documents, - retrieval_session, + retrieval_engine, ): """ Test basic hybrid search combining vector and full-text search. @@ -789,7 +786,9 @@ class TestRetrievalService: mock_embedding_search.assert_called_once() mock_fulltext_search.assert_called_once() mock_processor_instance.invoke.assert_called_once() - assert mock_data_processor_class.call_args.kwargs["session"] is retrieval_session + processor_session = mock_data_processor_class.call_args.kwargs["session"] + assert isinstance(processor_session, Session) + assert processor_session.get_bind() is retrieval_engine @patch("core.rag.datasource.retrieval_service.DataPostProcessor") @patch("core.rag.datasource.retrieval_service.RetrievalService.full_text_index_search") @@ -802,7 +801,7 @@ class TestRetrievalService: mock_fulltext_search, mock_data_processor_class, mock_dataset, - retrieval_session, + retrieval_engine, ): """ Test that hybrid search properly deduplicates documents. @@ -927,7 +926,9 @@ class TestRetrievalService: doc_ids = [doc.metadata["doc_id"] for doc in results] assert "duplicate_doc" in doc_ids, "Duplicate doc should be present (higher score version)" assert "unique_doc" in doc_ids, "Unique doc should be present" - assert mock_data_processor_class.call_args.kwargs["session"] is retrieval_session + processor_session = mock_data_processor_class.call_args.kwargs["session"] + assert isinstance(processor_session, Session) + assert processor_session.get_bind() is retrieval_engine # Implicitly verifies that doc1_low (score 0.6) was discarded # in favor of doc1_high (score 0.9) @@ -944,7 +945,7 @@ class TestRetrievalService: mock_data_processor_class, mock_dataset, sample_documents, - retrieval_session, + retrieval_engine, ): """ Test hybrid search with custom weights for score merging. @@ -1017,7 +1018,9 @@ class TestRetrievalService: mock_data_processor_class.assert_called_once() call_args = mock_data_processor_class.call_args assert call_args.args[3] == weights - assert call_args.kwargs["session"] is retrieval_session + processor_session = call_args.kwargs["session"] + assert isinstance(processor_session, Session) + assert processor_session.get_bind() is retrieval_engine @pytest.mark.parametrize("empty_query", ["", None]) @patch("core.rag.datasource.retrieval_service.DataPostProcessor") @@ -1031,7 +1034,7 @@ class TestRetrievalService: mock_dataset, sample_documents, empty_query, - retrieval_session, + retrieval_engine, ): """ Regression test for GH #37116: attachment-only hybrid retrieval must use IMAGE_QUERY. @@ -1085,7 +1088,9 @@ class TestRetrievalService: assert invoke_kwargs["query"] == attachment_id, ( "The rerank query must be the attachment_id, not the empty text query" ) - assert mock_data_processor_class.call_args.kwargs["session"] is retrieval_session + processor_session = mock_data_processor_class.call_args.kwargs["session"] + assert isinstance(processor_session, Session) + assert processor_session.get_bind() is retrieval_engine # ==================== Full-Text Search Tests ==================== @@ -1734,24 +1739,23 @@ class TestRetrievalService: all_documents = [] # Act - Call with dataset_count = 1 - with _patched_retriever_session(): - dataset_retrieval._multiple_retrieve_thread( - flask_app=mock_flask_app, - available_datasets=[mock_dataset], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - weights=None, - top_k=5, - score_threshold=0.5, - query="test query", - attachment_id=None, - dataset_count=1, # Single dataset - should skip second reranking - ) + dataset_retrieval._multiple_retrieve_thread( + flask_app=mock_flask_app, + available_datasets=[mock_dataset], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + weights=None, + top_k=5, + score_threshold=0.5, + query="test query", + attachment_id=None, + dataset_count=1, # Single dataset - should skip second reranking + ) # Assert # DataPostProcessor should NOT be called (second reranking skipped) @@ -1843,35 +1847,28 @@ class TestRetrievalService: ) # Act - Call with dataset_count = 2 - with _patched_retriever_session() as rerank_session: - dataset_retrieval._multiple_retrieve_thread( - flask_app=mock_flask_app, - available_datasets=[mock_dataset, mock_dataset2], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - weights=None, - top_k=5, - score_threshold=0.5, - query="test query", - attachment_id=None, - dataset_count=2, # Multiple datasets - should perform second reranking - ) + dataset_retrieval._multiple_retrieve_thread( + flask_app=mock_flask_app, + available_datasets=[mock_dataset, mock_dataset2], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + weights=None, + top_k=5, + score_threshold=0.5, + query="test query", + attachment_id=None, + dataset_count=2, # Multiple datasets - should perform second reranking + ) # Assert # DataPostProcessor SHOULD be called (second reranking performed) - mock_data_processor_class.assert_called_once_with( - tenant_id, - "reranking_model", - {"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - None, - False, - session=rerank_session, - ) + mock_data_processor_class.assert_called_once() + assert isinstance(mock_data_processor_class.call_args.kwargs["session"], Session) # Verify invoke was called with correct parameters mock_processor_instance.invoke.assert_called_once() @@ -1949,24 +1946,23 @@ class TestRetrievalService: all_documents = [] # Act - Call with dataset_count = 1 - with _patched_retriever_session(): - dataset_retrieval._multiple_retrieve_thread( - flask_app=mock_flask_app, - available_datasets=[mock_dataset], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, # Reranking enabled but should be skipped for single dataset - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - weights=None, - top_k=5, - score_threshold=0.5, - query="test query", - attachment_id=None, - dataset_count=1, - ) + dataset_retrieval._multiple_retrieve_thread( + flask_app=mock_flask_app, + available_datasets=[mock_dataset], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, # Reranking enabled but should be skipped for single dataset + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + weights=None, + top_k=5, + score_threshold=0.5, + query="test query", + attachment_id=None, + dataset_count=1, + ) # Assert # DataPostProcessor should NOT be called @@ -2334,11 +2330,10 @@ class TestCheckKnowledgeRateLimit: assert not mock_redis.zremrangebyscore.called assert not mock_redis.zcard.called - @patch("core.rag.retrieval.dataset_retrieval.session_factory") @patch("core.rag.retrieval.dataset_retrieval.FeatureService") @patch("core.rag.retrieval.dataset_retrieval.redis_client") @patch("core.rag.retrieval.dataset_retrieval.time") - def test_rate_limit_enabled_not_exceeded(self, mock_time, mock_redis, mock_feature_service, mock_session_factory): + def test_rate_limit_enabled_not_exceeded(self, mock_time, mock_redis, mock_feature_service): """ Test that when rate limit is enabled but not exceeded, no exception is raised. @@ -2372,11 +2367,6 @@ class TestCheckKnowledgeRateLimit: # zcard returns 50 (within limit of 100) mock_redis.zcard.return_value = 50 - # Mock session_factory.create_session - mock_session = MagicMock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - mock_session_factory.create_session.return_value.__exit__.return_value = None - # Act & Assert - should not raise any exception dataset_retrieval._check_knowledge_rate_limit(tenant_id) @@ -2386,12 +2376,11 @@ class TestCheckKnowledgeRateLimit: mock_redis.zremrangebyscore.assert_called_once_with(expected_key, 0, current_time - 60000) mock_redis.zcard.assert_called_once_with(expected_key) - @patch("core.rag.retrieval.dataset_retrieval.session_factory") @patch("core.rag.retrieval.dataset_retrieval.FeatureService") @patch("core.rag.retrieval.dataset_retrieval.redis_client") @patch("core.rag.retrieval.dataset_retrieval.time") def test_rate_limit_enabled_exceeded_raises_exception( - self, mock_time, mock_redis, mock_feature_service, mock_session_factory + self, mock_time, mock_redis, mock_feature_service, sqlite_session: Session ): """ Test that when rate limit is enabled and exceeded, RateLimitExceededError is raised. @@ -2424,11 +2413,6 @@ class TestCheckKnowledgeRateLimit: # Mock Redis operations - return count exceeding limit mock_redis.zcard.return_value = 150 # Exceeds limit of 100 - # Mock session_factory.create_session - mock_session = MagicMock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - mock_session_factory.create_session.return_value.__exit__.return_value = None - # Act & Assert with pytest.raises(exc.RateLimitExceededError) as exc_info: dataset_retrieval._check_knowledge_rate_limit(tenant_id) @@ -2436,9 +2420,9 @@ class TestCheckKnowledgeRateLimit: # Verify exception message assert "knowledge base request rate limit" in str(exc_info.value) - # Verify RateLimitLog was created - mock_session.add.assert_called_once() - added_log = mock_session.add.call_args[0][0] + # Verify the independently committed audit row from an observer session. + added_log = sqlite_session.scalar(select(RateLimitLog).where(RateLimitLog.tenant_id == tenant_id)) + assert added_log is not None assert added_log.tenant_id == tenant_id assert added_log.subscription_plan == "professional" assert added_log.operation == "knowledge" @@ -2546,9 +2530,7 @@ class TestDatasetRetrievalKnowledgeRetrieval: assert request.model_name == "gpt-4" assert request.model_mode == "chat" - @patch("core.rag.retrieval.dataset_retrieval.DataPostProcessor") - @patch("core.rag.retrieval.dataset_retrieval.session_factory") - def test_knowledge_retrieval_multiple_mode(self, mock_session_factory, mock_data_processor): + def test_knowledge_retrieval_multiple_mode(self, sqlite_session: Session): """ Test knowledge_retrieval in multiple retrieval mode. @@ -2597,63 +2579,29 @@ class TestDatasetRetrievalKnowledgeRetrieval: # Mock get_metadata_filter_condition with patch.object(dataset_retrieval, "get_metadata_filter_condition", return_value=(None, None)): # Mock multiple_retrieve to return documents - doc1 = create_mock_document_methods("Python is great", "doc1", score=0.9) - doc2 = create_mock_document_methods("Python is awesome", "doc2", score=0.8) + doc1 = create_mock_document_methods( + "Python is great", + "doc1", + score=0.9, + provider="external", + additional_metadata={"dataset_name": "test dataset", "title": "Python"}, + ) + doc2 = create_mock_document_methods( + "Python is awesome", + "doc2", + score=0.8, + provider="external", + additional_metadata={"dataset_name": "test dataset", "title": "Python 2"}, + ) with patch.object( dataset_retrieval, "multiple_retrieve", return_value=[doc1, doc2] ) as mock_multiple_retrieve: - # Mock format_retrieval_documents - mock_record = Mock() - mock_record.segment = Mock() - mock_record.segment.dataset_id = dataset_id1 - mock_record.segment.document_id = str(uuid4()) - mock_record.segment.index_node_hash = "hash123" - mock_record.segment.hit_count = 5 - mock_record.segment.word_count = 100 - mock_record.segment.position = 1 - mock_record.segment.get_sign_content.return_value = "Python is great" - mock_record.segment.answer = None - mock_record.score = 0.9 - mock_record.child_chunks = [] - mock_record.summary = None - mock_record.files = None + result = dataset_retrieval.knowledge_retrieval(sqlite_session, request) - mock_retrieval_service = Mock() - mock_retrieval_service.format_retrieval_documents.return_value = [mock_record] + assert len(result) == 2 + mock_multiple_retrieve.assert_called_once() - with patch( - "core.rag.retrieval.dataset_retrieval.RetrievalService", - return_value=mock_retrieval_service, - ): - # Mock database queries - mock_session = MagicMock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - mock_session_factory.create_session.return_value.__exit__.return_value = None - - mock_dataset_from_db = Mock() - mock_dataset_from_db.id = dataset_id1 - mock_dataset_from_db.name = "test_dataset" - - mock_document = Mock() - mock_document.id = str(uuid4()) - mock_document.name = "test_doc" - mock_document.data_source_type = "upload_file" - mock_document.doc_metadata = {} - - mock_datasets = MagicMock() - mock_datasets.all.return_value = [mock_dataset_from_db] - mock_documents = MagicMock() - mock_documents.all.return_value = [mock_document] - mock_session.scalars.side_effect = [mock_datasets, mock_documents] - - # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) - - # Assert - assert isinstance(result, list) - mock_multiple_retrieve.assert_called_once() - - def test_knowledge_retrieval_metadata_filtering_disabled(self): + def test_knowledge_retrieval_metadata_filtering_disabled(self, sqlite_session: Session): """ Test knowledge_retrieval with metadata filtering disabled. @@ -2696,14 +2644,14 @@ class TestDatasetRetrievalKnowledgeRetrieval: ) as mock_get_metadata: with patch.object(dataset_retrieval, "multiple_retrieve", return_value=[]): # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) + result = dataset_retrieval.knowledge_retrieval(sqlite_session, request) # Assert assert isinstance(result, list) # get_metadata_filter_condition should NOT be called when mode is "disabled" mock_get_metadata.assert_not_called() - def test_knowledge_retrieval_with_external_documents(self): + def test_knowledge_retrieval_with_external_documents(self, sqlite_session: Session): """ Test knowledge_retrieval with external documents. @@ -2754,14 +2702,14 @@ class TestDatasetRetrievalKnowledgeRetrieval: ) with patch.object(dataset_retrieval, "multiple_retrieve", return_value=[external_doc]): # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) + result = dataset_retrieval.knowledge_retrieval(sqlite_session, request) # Assert assert isinstance(result, list) if result: assert result[0].metadata.data_source_type == "external" - def test_knowledge_retrieval_empty_results(self): + def test_knowledge_retrieval_empty_results(self, sqlite_session: Session): """ Test knowledge_retrieval when no documents are found. @@ -2797,12 +2745,12 @@ class TestDatasetRetrievalKnowledgeRetrieval: # Mock multiple_retrieve to return empty list with patch.object(dataset_retrieval, "multiple_retrieve", return_value=[]): # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) + result = dataset_retrieval.knowledge_retrieval(sqlite_session, request) # Assert assert result == [] - def test_knowledge_retrieval_rate_limit_exceeded(self): + def test_knowledge_retrieval_rate_limit_exceeded(self, sqlite_session: Session): """ Test knowledge_retrieval when rate limit is exceeded. @@ -2837,9 +2785,9 @@ class TestDatasetRetrievalKnowledgeRetrieval: ): # Act & Assert with pytest.raises(exc.RateLimitExceededError): - dataset_retrieval.knowledge_retrieval(MagicMock(), request) + dataset_retrieval.knowledge_retrieval(sqlite_session, request) - def test_knowledge_retrieval_no_available_datasets(self): + def test_knowledge_retrieval_no_available_datasets(self, sqlite_session: Session): """ Test knowledge_retrieval when no datasets are available. @@ -2871,7 +2819,7 @@ class TestDatasetRetrievalKnowledgeRetrieval: # Mock _get_available_datasets to return empty list with patch.object(dataset_retrieval, "_get_available_datasets", return_value=[]): # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) + result = dataset_retrieval.knowledge_retrieval(sqlite_session, request) # Assert assert result == [] @@ -2924,11 +2872,8 @@ class TestProcessMetadataFilterFunc: 3. Adding appropriate SQLAlchemy expressions to the filters list 4. Returning the updated filters list - Mocking Strategy: - ================== - - Mock DatasetDocument.doc_metadata to avoid database dependencies - - Verify filter expressions are created correctly - - Test with various data types (str, int, float, list) + Tests compile expressions against the real mapped JSON field for string, + numeric, null, and collection operators. """ @pytest.fixture @@ -2941,58 +2886,6 @@ class TestProcessMetadataFilterFunc: """ return DatasetRetrieval() - @pytest.fixture - def mock_doc_metadata(self): - """ - Mock the DatasetDocument.doc_metadata JSON field. - - The method uses DatasetDocument.doc_metadata[metadata_name] to access - JSON fields. We mock this to avoid database dependencies. - - Returns: - Mock: Mocked doc_metadata attribute - """ - mock_metadata_field = MagicMock() - - # Create mock for string access - mock_string_access = MagicMock() - mock_string_access.like = MagicMock() - mock_string_access.notlike = MagicMock() - mock_string_access.__eq__ = MagicMock(return_value=MagicMock()) - mock_string_access.__ne__ = MagicMock(return_value=MagicMock()) - mock_string_access.in_ = MagicMock(return_value=MagicMock()) - - # Create mock for float access (for numeric comparisons) - mock_float_access = MagicMock() - mock_float_access.__eq__ = MagicMock(return_value=MagicMock()) - mock_float_access.__ne__ = MagicMock(return_value=MagicMock()) - mock_float_access.__lt__ = MagicMock(return_value=MagicMock()) - mock_float_access.__gt__ = MagicMock(return_value=MagicMock()) - mock_float_access.__le__ = MagicMock(return_value=MagicMock()) - mock_float_access.__ge__ = MagicMock(return_value=MagicMock()) - - # Create mock for null checks - mock_null_access = MagicMock() - mock_null_access.is_ = MagicMock(return_value=MagicMock()) - mock_null_access.isnot = MagicMock(return_value=MagicMock()) - - # Setup __getitem__ to return appropriate mock based on usage - def getitem_side_effect(name): - if name in ["author", "title", "category"]: - return mock_string_access - elif name in ["year", "price", "rating"]: - return mock_float_access - elif name == "description": - return mock_null_access - else: - return mock_string_access - - mock_metadata_field.__getitem__ = MagicMock(side_effect=getitem_side_effect) - mock_metadata_field.as_string.return_value = mock_string_access - mock_metadata_field.as_float.return_value = mock_float_access - - return mock_metadata_field - # ==================== String Condition Tests ==================== def test_contains_condition_string_value(self, retrieval): @@ -3818,7 +3711,6 @@ class TestKnowledgeRetrievalRegression: "core.rag.retrieval.dataset_retrieval.DataPostProcessor", ContextRequiredPostProcessor, ), - _patched_retriever_session(), ): dataset_retrieval._multiple_retrieve_thread_safely( flask_app=flask_app, @@ -3856,21 +3748,20 @@ class TestKnowledgeRetrievalRegression: dataset_retrieval = DatasetRetrieval() all_documents: list[Document] = [] - with _patched_retriever_session() as session: - with patch.object(dataset_retrieval, "_retriever") as mock_retriever: - dataset_retrieval._run_retriever_thread( - flask_app=_FakeFlaskApp(), - dataset_id="dataset-1", - query="test query", - top_k=3, - all_documents=all_documents, - document_ids_filter=None, - metadata_condition=None, - attachment_ids=None, - ) + with patch.object(dataset_retrieval, "_retriever") as mock_retriever: + dataset_retrieval._run_retriever_thread( + flask_app=_FakeFlaskApp(), + dataset_id="dataset-1", + query="test query", + top_k=3, + all_documents=all_documents, + document_ids_filter=None, + metadata_condition=None, + attachment_ids=None, + ) mock_retriever.assert_called_once() - assert mock_retriever.call_args.kwargs["session"] is session + assert isinstance(mock_retriever.call_args.kwargs["session"], Session) def test_run_retriever_thread_safely_records_retriever_exception(self): dataset_retrieval = DatasetRetrieval() @@ -3879,20 +3770,19 @@ class TestKnowledgeRetrievalRegression: thread_exceptions: list[Exception] = [] expected_error = RuntimeError("retrieval failed") - with _patched_retriever_session(): - with patch.object(dataset_retrieval, "_retriever", side_effect=expected_error): - dataset_retrieval._run_retriever_thread_safely( - flask_app=_FakeFlaskApp(), - dataset_id="dataset-1", - query="test query", - top_k=3, - all_documents=all_documents, - document_ids_filter=None, - metadata_condition=None, - attachment_ids=None, - cancel_event=cancel_event, - thread_exceptions=thread_exceptions, - ) + with patch.object(dataset_retrieval, "_retriever", side_effect=expected_error): + dataset_retrieval._run_retriever_thread_safely( + flask_app=_FakeFlaskApp(), + dataset_id="dataset-1", + query="test query", + top_k=3, + all_documents=all_documents, + document_ids_filter=None, + metadata_condition=None, + attachment_ids=None, + cancel_event=cancel_event, + thread_exceptions=thread_exceptions, + ) assert cancel_event.is_set() assert thread_exceptions == [expected_error] @@ -3904,21 +3794,20 @@ class TestKnowledgeRetrievalRegression: thread_exceptions: list[Exception] = [] expected_error = RuntimeError("retrieval failed") - with _patched_retriever_session(): - with patch.object(dataset_retrieval, "_retriever", side_effect=expected_error): - dataset_retrieval._run_retriever_thread_safely( - flask_app=_FakeFlaskApp(), - dataset_id="dataset-1", - query="test query", - top_k=3, - all_documents=all_documents, - document_ids_filter=None, - metadata_condition=None, - attachment_ids=None, - cancel_event=cancel_event, - thread_exceptions=thread_exceptions, - skip_on_error=True, - ) + with patch.object(dataset_retrieval, "_retriever", side_effect=expected_error): + dataset_retrieval._run_retriever_thread_safely( + flask_app=_FakeFlaskApp(), + dataset_id="dataset-1", + query="test query", + top_k=3, + all_documents=all_documents, + document_ids_filter=None, + metadata_condition=None, + attachment_ids=None, + cancel_event=cancel_event, + thread_exceptions=thread_exceptions, + skip_on_error=True, + ) assert not cancel_event.is_set() assert thread_exceptions == [] @@ -3961,10 +3850,7 @@ class TestKnowledgeRetrievalRegression: all_documents: list[Document] = [] - with ( - patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever), - _patched_retriever_session(), - ): + with patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever): dataset_retrieval._multiple_retrieve_thread( flask_app=flask_app, available_datasets=[mock_dataset, successful_dataset], @@ -4093,66 +3979,51 @@ class TestDatasetRetrievalAdditionalHelpers: retrieval._send_trace_task("m1", docs, {"cost": 1}) trace_manager.add_trace_task.assert_not_called() - def test_on_query(self, retrieval: DatasetRetrieval) -> None: - db_mock = Mock() - audit_session = MagicMock() - session_factory = MagicMock() - session_factory.begin.return_value.__enter__.return_value = audit_session + def test_on_query(self, retrieval: DatasetRetrieval, sqlite_engine: Engine, sqlite_session: Session) -> None: + dataset_ids = [str(uuid4()), str(uuid4())] + app_id = str(uuid4()) + user_id = str(uuid4()) - with ( - patch("core.rag.retrieval.dataset_retrieval.db", db_mock), - patch( - "core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=session_factory - ) as sessionmaker_mock, - ): + with patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=sqlite_engine)): retrieval._on_query( query=None, attachment_ids=None, - dataset_ids=["d1"], - app_id="a1", + dataset_ids=[dataset_ids[0]], + app_id=app_id, user_from="account", - user_id="u1", + user_id=user_id, ) - audit_session.add_all.assert_not_called() + assert sqlite_session.scalars(select(DatasetQuery)).all() == [] retrieval._on_query( query="python", - attachment_ids=["f1"], - dataset_ids=["d1", "d2"], - app_id="a1", + attachment_ids=[str(uuid4())], + dataset_ids=dataset_ids, + app_id=app_id, user_from="account", - user_id="u1", + user_id=user_id, ) - sessionmaker_mock.assert_called_once_with(bind=db_mock.engine, expire_on_commit=False) - audit_session.add_all.assert_called_once() - added_queries = audit_session.add_all.call_args.args[0] - assert len(added_queries) == 2 - db_mock.session.commit.assert_not_called() + added_queries = sqlite_session.scalars(select(DatasetQuery).order_by(DatasetQuery.dataset_id)).all() + assert [row.dataset_id for row in added_queries] == sorted(dataset_ids) + assert all(row.created_by == user_id for row in added_queries) - def test_on_query_normalizes_workflow_end_user_role(self, retrieval: DatasetRetrieval) -> None: - db_mock = Mock() - audit_session = MagicMock() - session_factory = MagicMock() - session_factory.begin.return_value.__enter__.return_value = audit_session - - with ( - patch("core.rag.retrieval.dataset_retrieval.db", db_mock), - patch("core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=session_factory), - ): + def test_on_query_normalizes_workflow_end_user_role( + self, retrieval: DatasetRetrieval, sqlite_engine: Engine, sqlite_session: Session + ) -> None: + dataset_id = str(uuid4()) + with patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=sqlite_engine)): retrieval._on_query( query="python", attachment_ids=None, - dataset_ids=["d1"], - app_id="a1", + dataset_ids=[dataset_id], + app_id=str(uuid4()), user_from="end-user", - user_id="u1", + user_id=str(uuid4()), ) - audit_session.add_all.assert_called_once() - added_queries = audit_session.add_all.call_args.args[0] - - assert len(added_queries) == 1 - assert added_queries[0].created_by_role == CreatorUserRole.END_USER + added_query = sqlite_session.scalar(select(DatasetQuery).where(DatasetQuery.dataset_id == dataset_id)) + assert added_query is not None + assert added_query.created_by_role == CreatorUserRole.END_USER def test_handle_invoke_result(self, retrieval: DatasetRetrieval) -> None: usage = LLMUsage.empty_usage() @@ -4302,8 +4173,20 @@ class TestDatasetRetrievalAdditionalHelpers: assert config.stop == ["END"] assert "stop" not in config.parameters - def test_automatic_metadata_filter_func(self, retrieval: DatasetRetrieval) -> None: - metadata_field = SimpleNamespace(name="author") + def test_automatic_metadata_filter_func(self, retrieval: DatasetRetrieval, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + dataset_id = str(uuid4()) + user_id = str(uuid4()) + sqlite_session.add( + DatasetMetadata( + tenant_id=tenant_id, + dataset_id=dataset_id, + type="string", + name="author", + created_by=user_id, + ) + ) + sqlite_session.commit() model_instance = Mock() model_instance.invoke_llm.return_value = iter([Mock()]) model_config = ModelConfigWithCredentialsEntity.model_construct( @@ -4317,11 +4200,6 @@ class TestDatasetRetrievalAdditionalHelpers: stop=[], ) usage = LLMUsage.from_metadata({"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}) - session_scalars = Mock() - session_scalars.all.return_value = [metadata_field] - session = MagicMock() - session.scalars.return_value = session_scalars - with ( patch.object(retrieval, "_fetch_model_config", return_value=(model_instance, model_config)), patch.object(retrieval, "_get_prompt_template", return_value=(["prompt"], [])), @@ -4344,11 +4222,11 @@ class TestDatasetRetrievalAdditionalHelpers: ] } result = retrieval._automatic_metadata_filter_func( - session, - dataset_ids=["d1"], + sqlite_session, + dataset_ids=[dataset_id], query="python", - tenant_id="tenant-1", - user_id="u1", + tenant_id=tenant_id, + user_id=user_id, metadata_model_config=AppModelConfig(provider="openai", name="gpt", mode="chat"), ) @@ -4360,23 +4238,41 @@ class TestDatasetRetrievalAdditionalHelpers: ): with pytest.raises(RuntimeError, match="boom"): retrieval._automatic_metadata_filter_func( - session, - dataset_ids=["d1"], + sqlite_session, + dataset_ids=[dataset_id], query="python", - tenant_id="tenant-1", - user_id="u1", + tenant_id=tenant_id, + user_id=user_id, metadata_model_config=AppModelConfig(provider="openai", name="gpt", mode="chat"), ) - def test_get_metadata_filter_condition(self, retrieval: DatasetRetrieval) -> None: - scalars_result = Mock() - scalars_result.all.return_value = [SimpleNamespace(dataset_id="d1", id="doc-1")] - session = MagicMock() - session.scalars.return_value = scalars_result + def test_get_metadata_filter_condition(self, retrieval: DatasetRetrieval, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + dataset_id = str(uuid4()) + document_id = str(uuid4()) + user_id = str(uuid4()) + sqlite_session.add( + DatasetDocument( + id=document_id, + tenant_id=tenant_id, + dataset_id=dataset_id, + position=1, + data_source_type="upload_file", + batch="batch-1", + name="document", + created_from="api", + created_by=user_id, + indexing_status="completed", + enabled=True, + archived=False, + doc_metadata={"author": "Alice"}, + ) + ) + sqlite_session.commit() mapping, condition = retrieval.get_metadata_filter_condition( - session, - dataset_ids=["d1"], + sqlite_session, + dataset_ids=[dataset_id], query="python", tenant_id="tenant-1", user_id="u1", @@ -4393,8 +4289,8 @@ class TestDatasetRetrievalAdditionalHelpers: patch.object(retrieval, "_automatic_metadata_filter_func", return_value=automatic_filters), ): mapping, condition = retrieval.get_metadata_filter_condition( - session, - dataset_ids=["d1"], + sqlite_session, + dataset_ids=[dataset_id], query="python", tenant_id="tenant-1", user_id="u1", @@ -4403,7 +4299,7 @@ class TestDatasetRetrievalAdditionalHelpers: metadata_filtering_conditions=AppMetadataFilteringCondition(logical_operator="or", conditions=[]), inputs={}, ) - assert mapping == {"d1": ["doc-1"]} + assert mapping == {dataset_id: [document_id]} assert condition is not None assert condition.logical_operator == "or" @@ -4412,8 +4308,8 @@ class TestDatasetRetrievalAdditionalHelpers: conditions=[AppCondition(name="author", comparison_operator="contains", value="{{name}}")], ) mapping, condition = retrieval.get_metadata_filter_condition( - session, - dataset_ids=["d1"], + sqlite_session, + dataset_ids=[dataset_id], query="python", tenant_id="tenant-1", user_id="u1", @@ -4422,7 +4318,7 @@ class TestDatasetRetrievalAdditionalHelpers: metadata_filtering_conditions=manual_conditions, inputs={"name": "Alice"}, ) - assert mapping == {"d1": ["doc-1"]} + assert mapping == {dataset_id: [document_id]} assert condition is not None assert condition.conditions first_condition = condition.conditions[0] @@ -4430,8 +4326,8 @@ class TestDatasetRetrievalAdditionalHelpers: with pytest.raises(ValueError, match="Invalid metadata filtering mode"): retrieval.get_metadata_filter_condition( - session, - dataset_ids=["d1"], + sqlite_session, + dataset_ids=[dataset_id], query="python", tenant_id="tenant-1", user_id="u1", @@ -4441,22 +4337,71 @@ class TestDatasetRetrievalAdditionalHelpers: inputs={}, ) - def test_get_available_datasets(self, retrieval: DatasetRetrieval) -> None: - session = Mock() - scalars_result = Mock() - scalars_result.all.return_value = [SimpleNamespace(id="d1"), None, SimpleNamespace(id="d2")] - session.scalars.return_value = scalars_result + def test_get_available_datasets(self, retrieval: DatasetRetrieval, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + other_tenant_id = str(uuid4()) + creator_id = str(uuid4()) + internal_id = str(uuid4()) + external_id = str(uuid4()) + unavailable_id = str(uuid4()) + decoy_id = str(uuid4()) + datasets = [ + Dataset( + id=internal_id, + tenant_id=tenant_id, + name="internal", + provider="dify", + indexing_technique="high_quality", + created_by=creator_id, + ), + Dataset( + id=external_id, + tenant_id=tenant_id, + name="external", + provider="external", + indexing_technique="high_quality", + created_by=creator_id, + ), + Dataset( + id=unavailable_id, + tenant_id=tenant_id, + name="unavailable", + provider="dify", + indexing_technique="high_quality", + created_by=creator_id, + ), + Dataset( + id=decoy_id, + tenant_id=other_tenant_id, + name="decoy", + provider="external", + indexing_technique="high_quality", + created_by=creator_id, + ), + ] + sqlite_session.add_all(datasets) + sqlite_session.add( + DatasetDocument( + tenant_id=tenant_id, + dataset_id=internal_id, + position=1, + data_source_type="upload_file", + batch="batch-1", + name="available document", + created_from="api", + created_by=creator_id, + indexing_status="completed", + enabled=True, + archived=False, + ) + ) + sqlite_session.commit() - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False + available = retrieval._get_available_datasets(tenant_id, [internal_id, external_id, unavailable_id, decoy_id]) - with patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session", return_value=session_ctx): - available = retrieval._get_available_datasets("tenant-1", ["d1", "d2"]) + assert {dataset.id for dataset in available} == {internal_id, external_id} - assert [dataset.id for dataset in available] == ["d1", "d2"] - - def test_check_knowledge_rate_limit(self, retrieval: DatasetRetrieval) -> None: + def test_check_knowledge_rate_limit(self, retrieval: DatasetRetrieval, sqlite_session: Session) -> None: with ( patch("core.rag.retrieval.dataset_retrieval.FeatureService.get_knowledge_rate_limit") as mock_limit, patch("core.rag.retrieval.dataset_retrieval.redis_client") as mock_redis, @@ -4467,22 +4412,18 @@ class TestDatasetRetrievalAdditionalHelpers: retrieval._check_knowledge_rate_limit("tenant-1") mock_redis.zadd.assert_called_once() - session = Mock() - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False - + tenant_id = str(uuid4()) with ( patch("core.rag.retrieval.dataset_retrieval.FeatureService.get_knowledge_rate_limit") as mock_limit, patch("core.rag.retrieval.dataset_retrieval.redis_client") as mock_redis, patch("core.rag.retrieval.dataset_retrieval.time.time", return_value=100.0), - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session", return_value=session_ctx), ): mock_limit.return_value = SimpleNamespace(enabled=True, limit=1, subscription_plan="pro") mock_redis.zcard.return_value = 2 with pytest.raises(exc.RateLimitExceededError): - retrieval._check_knowledge_rate_limit("tenant-1") - session.add.assert_called_once() + retrieval._check_knowledge_rate_limit(tenant_id) + rate_limit_log = sqlite_session.scalar(select(RateLimitLog).where(RateLimitLog.tenant_id == tenant_id)) + assert rate_limit_log is not None with patch("core.rag.retrieval.dataset_retrieval.FeatureService.get_knowledge_rate_limit") as mock_limit: mock_limit.return_value = SimpleNamespace(enabled=False) @@ -4554,6 +4495,10 @@ def _timer(): class TestKnowledgeRetrievalCoverage: + @pytest.fixture(autouse=True) + def _orm_session(self, sqlite_session: Session) -> None: + self.orm_session = sqlite_session + @pytest.fixture def retrieval(self) -> DatasetRetrieval: return DatasetRetrieval() @@ -4570,9 +4515,9 @@ class TestKnowledgeRetrievalCoverage: ) with ( patch.object(retrieval, "_check_knowledge_rate_limit"), - patch.object(retrieval, "_get_available_datasets", return_value=[SimpleNamespace(id="d1")]), + patch.object(retrieval, "_get_available_datasets", return_value=[_dataset(id="d1")]), ): - assert retrieval.knowledge_retrieval(MagicMock(), request) == [] + assert retrieval.knowledge_retrieval(self.orm_session, request) == [] def test_raises_when_metadata_model_config_missing(self, retrieval: DatasetRetrieval) -> None: request = KnowledgeRetrievalRequest( @@ -4588,10 +4533,10 @@ class TestKnowledgeRetrievalCoverage: ) with ( patch.object(retrieval, "_check_knowledge_rate_limit"), - patch.object(retrieval, "_get_available_datasets", return_value=[SimpleNamespace(id="d1")]), + patch.object(retrieval, "_get_available_datasets", return_value=[_dataset(id="d1")]), ): with pytest.raises(ValueError, match="metadata_model_config is required"): - retrieval.knowledge_retrieval(MagicMock(), request) + retrieval.knowledge_retrieval(self.orm_session, request) @pytest.mark.parametrize( ("status", "error_cls"), @@ -4629,17 +4574,21 @@ class TestKnowledgeRetrievalCoverage: ) with ( patch.object(retrieval, "_check_knowledge_rate_limit"), - patch.object(retrieval, "_get_available_datasets", return_value=[SimpleNamespace(id="dataset-1")]), + patch.object(retrieval, "_get_available_datasets", return_value=[_dataset(id="dataset-1")]), patch("core.rag.retrieval.dataset_retrieval.ModelManager.for_tenant") as mock_model_manager, ): mock_model_manager.return_value.get_model_instance.return_value = model_instance with pytest.raises(Exception) as exc_info: - retrieval.knowledge_retrieval(MagicMock(), request) + retrieval.knowledge_retrieval(self.orm_session, request) mock_model_manager.assert_called_once_with(tenant_id="tenant-1", user_id="user-1") assert error_cls in type(exc_info.value).__name__ class TestRetrieveCoverage: + @pytest.fixture(autouse=True) + def _orm_session(self, sqlite_session: Session) -> None: + self.orm_session = sqlite_session + @pytest.fixture def retrieval(self) -> DatasetRetrieval: return DatasetRetrieval() @@ -4667,7 +4616,7 @@ class TestRetrieveCoverage: ), ) result = retrieval.retrieve( - MagicMock(), + self.orm_session, app_id="app-1", user_id="user-1", tenant_id="tenant-1", @@ -4697,7 +4646,7 @@ class TestRetrieveCoverage: with patch("core.rag.retrieval.dataset_retrieval.ModelManager.for_tenant") as mock_model_manager: mock_model_manager.return_value.get_model_instance.return_value = model_instance result = retrieval.retrieve( - MagicMock(), + self.orm_session, app_id="app-1", user_id="user-1", tenant_id="tenant-1", @@ -4734,13 +4683,13 @@ class TestRetrieveCoverage: with ( patch("core.rag.retrieval.dataset_retrieval.ModelManager.for_tenant") as mock_model_manager, - patch.object(retrieval, "_get_available_datasets", return_value=[SimpleNamespace(id="d1")]), + patch.object(retrieval, "_get_available_datasets", return_value=[_dataset(id="d1")]), patch.object(retrieval, "get_metadata_filter_condition", return_value=(None, None)), patch.object(retrieval, "single_retrieve", return_value=[]) as mock_single_retrieve, ): mock_model_manager.return_value.get_model_instance.return_value = bound_model_instance context, files = retrieval.retrieve( - MagicMock(), + self.orm_session, app_id="app-1", user_id="user-1", tenant_id="tenant-1", @@ -4779,7 +4728,7 @@ class TestRetrieveCoverage: ) with ( patch("core.rag.retrieval.dataset_retrieval.ModelManager.for_tenant") as mock_model_manager, - patch.object(retrieval, "_get_available_datasets", return_value=[SimpleNamespace(id="d1")]), + patch.object(retrieval, "_get_available_datasets", return_value=[_dataset(id="d1")]), patch.object(retrieval, "get_metadata_filter_condition", return_value=(None, None)), patch.object(retrieval, "single_retrieve", return_value=[external_doc]), ): @@ -4790,7 +4739,7 @@ class TestRetrieveCoverage: bound_model_instance.model_type_instance.get_model_schema.return_value = SimpleNamespace(features=[]) mock_model_manager.return_value.get_model_instance.return_value = bound_model_instance context, files = retrieval.retrieve( - MagicMock(), + self.orm_session, app_id="app-1", user_id="user-1", tenant_id="tenant-1", @@ -4805,7 +4754,13 @@ class TestRetrieveCoverage: assert context == "external content" assert files == [] - def test_multiple_strategy_with_vision_and_source_details(self, retrieval: DatasetRetrieval) -> None: + def test_multiple_strategy_with_vision_and_source_details( + self, retrieval: DatasetRetrieval, sqlite_session: Session + ) -> None: + tenant_id = str(uuid4()) + creator_id = str(uuid4()) + dataset_id = str(uuid4()) + document_id = str(uuid4()) retrieve_config = DatasetRetrieveConfigEntity( retrieve_strategy=DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE, top_k=4, @@ -4815,7 +4770,7 @@ class TestRetrieveCoverage: reranking_enabled=True, metadata_filtering_mode="disabled", ) - config = DatasetEntity(dataset_ids=["d1"], retrieve_config=retrieve_config) + config = DatasetEntity(dataset_ids=[dataset_id], retrieve_config=retrieve_config) model_config = self._build_model_config(features=[ModelFeature.TOOL_CALL]) external_doc = _doc( provider="external", @@ -4830,54 +4785,85 @@ class TestRetrieveCoverage: provider="dify", content="dify body", score=0.9, - dataset_id="d1", - document_id="doc-1", + dataset_id=dataset_id, + document_id=document_id, doc_id="node-1", ) + dataset_item = Dataset( + id=dataset_id, + tenant_id=tenant_id, + name="Dataset One", + provider="dify", + indexing_technique="high_quality", + created_by=creator_id, + ) + document_item = DatasetDocument( + id=document_id, + tenant_id=tenant_id, + dataset_id=dataset_id, + position=1, + data_source_type="upload_file", + batch="batch-1", + name="Document One", + created_from="api", + created_by=creator_id, + indexing_status="completed", + enabled=True, + archived=False, + doc_metadata={"lang": "en"}, + ) + segment = DocumentSegment( + tenant_id=tenant_id, + dataset_id=dataset_id, + document_id=document_id, + position=1, + content="segment content", + word_count=11, + tokens=3, + created_by=creator_id, + index_node_id="node-1", + index_node_hash="hash-1", + status="completed", + hit_count=3, + answer="segment answer", + ) + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.LOCAL, + key="k1", + name="image", + size=123, + extension="png", + mime_type="image/png", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=creator_id, + created_at=datetime.now(), + used=True, + source_url="https://example.com/img.png", + ) + sqlite_session.add_all([dataset_item, document_item, segment, upload_file]) + sqlite_session.flush() + sqlite_session.add( + SegmentAttachmentBinding( + tenant_id=tenant_id, + dataset_id=dataset_id, + document_id=document_id, + segment_id=segment.id, + attachment_id=upload_file.id, + ) + ) + sqlite_session.commit() record = SimpleNamespace( - segment=SimpleNamespace( - id="segment-1", - dataset_id="d1", - document_id="doc-1", - tenant_id="tenant-1", - hit_count=3, - word_count=11, - position=1, - index_node_hash="hash-1", - content="segment content", - answer="segment answer", - get_sign_content=lambda: "segment content", - ), + segment=segment, score=0.9, summary="short summary", files=None, ) - dataset_item = SimpleNamespace(id="d1", name="Dataset One") - document_item = SimpleNamespace( - id="doc-1", - name="Document One", - data_source_type="upload_file", - doc_metadata={"lang": "en"}, - ) - upload_file = SimpleNamespace( - id="file-1", - name="image", - extension="png", - mime_type="image/png", - source_url="https://example.com/img.png", - size=123, - key="k1", - ) - execute_attachments = SimpleNamespace(all=lambda: [(SimpleNamespace(), upload_file)]) - execute_docs = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: [document_item])) - execute_datasets = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: [dataset_item])) hit_callback = Mock() - session = MagicMock() - session.execute.side_effect = [execute_attachments, execute_docs, execute_datasets] with ( patch("core.rag.retrieval.dataset_retrieval.ModelManager.for_tenant") as mock_model_manager, - patch.object(retrieval, "_get_available_datasets", return_value=[SimpleNamespace(id="d1")]), + patch.object(retrieval, "_get_available_datasets", return_value=[dataset_item]), patch.object(retrieval, "get_metadata_filter_condition", return_value=(None, None)), patch.object(retrieval, "multiple_retrieve", return_value=[external_doc, dify_doc]), patch( @@ -4895,10 +4881,10 @@ class TestRetrieveCoverage: ) mock_model_manager.return_value.get_model_instance.return_value = bound_model_instance context, files = retrieval.retrieve( - session, + sqlite_session, app_id="app-1", user_id="user-1", - tenant_id="tenant-1", + tenant_id=tenant_id, model_config=model_config, config=config, query="python", @@ -4916,24 +4902,31 @@ class TestRetrieveCoverage: class TestSingleAndMultipleRetrieveCoverage: + @pytest.fixture(autouse=True) + def _orm_session(self, sqlite_session: Session) -> None: + self.orm_session = sqlite_session + @pytest.fixture def retrieval(self) -> DatasetRetrieval: return DatasetRetrieval() def test_single_retrieve_external_path(self, retrieval: DatasetRetrieval) -> None: + dataset_id = str(uuid4()) + tenant_id = str(uuid4()) dataset = _dataset( - id="ds-1", + id=dataset_id, name="External DS", description=None, provider="external", - tenant_id="tenant-1", + tenant_id=tenant_id, + created_by=str(uuid4()), retrieval_model={"top_k": 2}, indexing_technique="high_quality", ) app = Flask(__name__) usage = LLMUsage.from_metadata({"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}) - session = MagicMock() - session.scalar.return_value = dataset + self.orm_session.add(dataset) + self.orm_session.commit() with app.app_context(): with ( patch("core.rag.retrieval.dataset_retrieval.ReactMultiDatasetRouter") as mock_router_cls, @@ -4944,14 +4937,14 @@ class TestSingleAndMultipleRetrieveCoverage: patch.object(retrieval, "_on_retrieval_end") as mock_end, patch.object(retrieval, "_on_query"), ): - mock_router_cls.return_value.invoke.return_value = ("ds-1", usage) + mock_router_cls.return_value.invoke.return_value = (dataset_id, usage) mock_external.return_value = [ {"content": "ext result", "metadata": {"k": "v"}, "score": 0.9, "title": "Ext Doc"} ] result = retrieval.single_retrieve( - session, + self.orm_session, app_id="app-1", - tenant_id="tenant-1", + tenant_id=tenant_id, user_id="user-1", user_from="workflow", query="python", @@ -4964,17 +4957,20 @@ class TestSingleAndMultipleRetrieveCoverage: assert len(result) == 1 assert result[0].provider == "external" - session.scalar.assert_called_once() mock_end.assert_called_once() assert retrieval.llm_usage.total_tokens == 2 def test_single_retrieve_dify_path_and_filters(self, retrieval: DatasetRetrieval) -> None: + dataset_id = str(uuid4()) + tenant_id = str(uuid4()) + document_id = str(uuid4()) dataset = _dataset( - id="ds-1", + id=dataset_id, name="Internal DS", description="dataset desc", provider="dify", - tenant_id="tenant-1", + tenant_id=tenant_id, + created_by=str(uuid4()), indexing_technique="high_quality", retrieval_model={ "search_method": "semantic_search", @@ -4989,9 +4985,9 @@ class TestSingleAndMultipleRetrieveCoverage: ) app = Flask(__name__) usage = LLMUsage.from_metadata({"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1}) - result_doc = _doc(provider="dify", score=0.7, dataset_id="ds-1", document_id="doc-1", doc_id="node-1") - session = MagicMock() - session.scalar.return_value = dataset + result_doc = _doc(provider="dify", score=0.7, dataset_id=dataset_id, document_id=document_id, doc_id="node-1") + self.orm_session.add(dataset) + self.orm_session.commit() with app.app_context(): with ( patch("core.rag.retrieval.dataset_retrieval.FunctionCallMultiDatasetRouter") as mock_router_cls, @@ -5002,11 +4998,11 @@ class TestSingleAndMultipleRetrieveCoverage: patch.object(retrieval, "_on_retrieval_end"), patch.object(retrieval, "_on_query"), ): - mock_router_cls.return_value.invoke.return_value = ("ds-1", usage) + mock_router_cls.return_value.invoke.return_value = (dataset_id, usage) results = retrieval.single_retrieve( - session, + self.orm_session, app_id="app-1", - tenant_id="tenant-1", + tenant_id=tenant_id, user_id="user-1", user_from="workflow", query="python", @@ -5014,19 +5010,19 @@ class TestSingleAndMultipleRetrieveCoverage: model_instance=Mock(), model_config=Mock(), planning_strategy=PlanningStrategy.ROUTER, - metadata_filter_document_ids={"ds-1": ["doc-1"]}, + metadata_filter_document_ids={dataset_id: [document_id]}, metadata_condition=_metadata_condition(), ) assert results == [result_doc] - assert mock_retrieve.call_args.kwargs["document_ids_filter"] == ["doc-1"] + assert mock_retrieve.call_args.kwargs["document_ids_filter"] == [document_id] assert retrieval.llm_usage.total_tokens == 1 def test_single_retrieve_returns_empty_when_no_dataset_selected(self, retrieval: DatasetRetrieval) -> None: with patch("core.rag.retrieval.dataset_retrieval.ReactMultiDatasetRouter") as mock_router_cls: mock_router_cls.return_value.invoke.return_value = (None, LLMUsage.empty_usage()) results = retrieval.single_retrieve( - MagicMock(), + self.orm_session, app_id="app-1", tenant_id="tenant-1", user_id="user-1", @@ -5131,25 +5127,28 @@ class TestSingleAndMultipleRetrieveCoverage: mock_on_query.assert_not_called() def test_single_retrieve_respects_metadata_filter_shortcuts(self, retrieval: DatasetRetrieval) -> None: + dataset_id = str(uuid4()) + tenant_id = str(uuid4()) dataset = _dataset( - id="ds-1", + id=dataset_id, name="Internal DS", description="desc", provider="dify", - tenant_id="tenant-1", + tenant_id=tenant_id, + created_by=str(uuid4()), indexing_technique="high_quality", retrieval_model={"top_k": 2, "search_method": "semantic_search", "reranking_enable": False}, ) with ( patch("core.rag.retrieval.dataset_retrieval.ReactMultiDatasetRouter") as mock_router_cls, ): - session = MagicMock() - session.scalar.return_value = dataset - mock_router_cls.return_value.invoke.return_value = ("ds-1", LLMUsage.empty_usage()) + self.orm_session.add(dataset) + self.orm_session.commit() + mock_router_cls.return_value.invoke.return_value = (dataset_id, LLMUsage.empty_usage()) no_filter = retrieval.single_retrieve( - session, + self.orm_session, app_id="app-1", - tenant_id="tenant-1", + tenant_id=tenant_id, user_id="user-1", user_from="workflow", query="python", @@ -5161,9 +5160,9 @@ class TestSingleAndMultipleRetrieveCoverage: metadata_condition=_metadata_condition(), ) missing_doc_ids = retrieval.single_retrieve( - session, + self.orm_session, app_id="app-1", - tenant_id="tenant-1", + tenant_id=tenant_id, user_id="user-1", user_from="workflow", query="python", @@ -5342,6 +5341,11 @@ class TestSingleAndMultipleRetrieveCoverage: class TestInternalHooksCoverage: + @pytest.fixture(autouse=True) + def _orm_resources(self, sqlite_engine: Engine, sqlite_session: Session) -> None: + self.orm_engine = sqlite_engine + self.orm_session = sqlite_session + @pytest.fixture def retrieval(self) -> DatasetRetrieval: return DatasetRetrieval() @@ -5361,7 +5365,7 @@ class TestInternalHooksCoverage: app = Flask(__name__) doc = Document(page_content="x", metadata={"doc_id": "n1"}, provider="dify") with ( - patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=Mock())), + patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=self.orm_engine)), patch.object(retrieval, "_send_trace_task") as mock_trace, ): retrieval._on_retrieval_end(flask_app=app, documents=[doc], message_id="m1", timer={"cost": 1}) @@ -5369,62 +5373,137 @@ class TestInternalHooksCoverage: def test_on_retrieval_end_updates_segments_for_text_and_image(self, retrieval: DatasetRetrieval) -> None: app = Flask(__name__) - docs = [ - _doc(provider="dify", document_id="doc-a", doc_id="idx-a", extra={"doc_type": "text"}), - _doc(provider="dify", document_id="doc-b", doc_id="att-b", extra={"doc_type": DocType.IMAGE}), - _doc(provider="dify", document_id="doc-c", doc_id="idx-c", extra={"doc_type": "text"}), - _doc(provider="dify", document_id="doc-d", doc_id="att-d", extra={"doc_type": DocType.IMAGE}), - ] + tenant_id = str(uuid4()) + dataset_id = str(uuid4()) + creator_id = str(uuid4()) + document_ids = [str(uuid4()) for _ in range(4)] dataset_docs = [ - SimpleNamespace(id="doc-a", doc_form=IndexStructureType.PARENT_CHILD_INDEX), - SimpleNamespace(id="doc-b", doc_form=IndexStructureType.PARENT_CHILD_INDEX), - SimpleNamespace(id="doc-c", doc_form=IndexStructureType.QA_INDEX), - SimpleNamespace(id="doc-d", doc_form=IndexStructureType.QA_INDEX), + DatasetDocument( + id=document_id, + tenant_id=tenant_id, + dataset_id=dataset_id, + position=position, + data_source_type="upload_file", + batch="batch-1", + name=f"document-{position}", + created_from="api", + created_by=creator_id, + indexing_status="completed", + enabled=True, + archived=False, + doc_form=doc_form, + ) + for position, (document_id, doc_form) in enumerate( + zip( + document_ids, + [ + IndexStructureType.PARENT_CHILD_INDEX, + IndexStructureType.PARENT_CHILD_INDEX, + IndexStructureType.QA_INDEX, + IndexStructureType.QA_INDEX, + ], + strict=True, + ), + start=1, + ) ] - child_chunks = [SimpleNamespace(index_node_id="idx-a", segment_id="seg-a")] - segments = [SimpleNamespace(index_node_id="idx-c", id="seg-c")] - bindings = [SimpleNamespace(segment_id="seg-b"), SimpleNamespace(segment_id="seg-d")] - - def _scalars(items): - result = Mock() - result.all.return_value = items - return result - - session = Mock() - session.scalars.side_effect = [ - _scalars(dataset_docs), - _scalars(child_chunks), - _scalars(segments), - _scalars(bindings), + segments = [ + DocumentSegment( + tenant_id=tenant_id, + dataset_id=dataset_id, + document_id=document_id, + position=1, + content=f"segment-{position}", + word_count=1, + tokens=1, + created_by=creator_id, + index_node_id="idx-c" if position == 3 else None, + status="completed", + ) + for position, document_id in enumerate(document_ids, start=1) + ] + self.orm_session.add_all(dataset_docs + segments) + self.orm_session.flush() + self.orm_session.add_all( + [ + ChildChunk( + tenant_id=tenant_id, + dataset_id=dataset_id, + document_id=document_ids[0], + segment_id=segments[0].id, + position=1, + content="child", + word_count=1, + created_by=creator_id, + index_node_id="idx-a", + ), + SegmentAttachmentBinding( + tenant_id=tenant_id, + dataset_id=dataset_id, + document_id=document_ids[1], + segment_id=segments[1].id, + attachment_id=str(uuid4()), + ), + SegmentAttachmentBinding( + tenant_id=tenant_id, + dataset_id=dataset_id, + document_id=document_ids[3], + segment_id=segments[3].id, + attachment_id=str(uuid4()), + ), + ] + ) + self.orm_session.commit() + docs = [ + _doc(provider="dify", document_id=document_ids[0], doc_id="idx-a", extra={"doc_type": "text"}), + _doc( + provider="dify", + document_id=document_ids[1], + doc_id=self.orm_session.scalar( + select(SegmentAttachmentBinding.attachment_id).where( + SegmentAttachmentBinding.segment_id == segments[1].id + ) + ), + extra={"doc_type": DocType.IMAGE}, + ), + _doc(provider="dify", document_id=document_ids[2], doc_id="idx-c", extra={"doc_type": "text"}), + _doc( + provider="dify", + document_id=document_ids[3], + doc_id=self.orm_session.scalar( + select(SegmentAttachmentBinding.attachment_id).where( + SegmentAttachmentBinding.segment_id == segments[3].id + ) + ), + extra={"doc_type": DocType.IMAGE}, + ), ] - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False - - sessionmaker_ctx = MagicMock() - sessionmaker_ctx.begin.return_value = session_ctx with ( - patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=Mock())), - patch("core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=sessionmaker_ctx), + patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=self.orm_engine)), patch.object(retrieval, "_send_trace_task") as mock_trace, ): retrieval._on_retrieval_end(flask_app=app, documents=docs, message_id="m1", timer={"cost": 1}) - session.execute.assert_called_once() + self.orm_session.expire_all() + assert [segment.hit_count for segment in segments] == [1, 1, 1, 1] mock_trace.assert_called_once() def test_retriever_variants(self, retrieval: DatasetRetrieval) -> None: flask_app = SimpleNamespace(app_context=lambda: nullcontext()) all_documents: list[Document] = [] + tenant_id = str(uuid4()) + creator_id = str(uuid4()) + missing_id = str(uuid4()) + external_id = str(uuid4()) + economy_id = str(uuid4()) + high_quality_id = str(uuid4()) - session = MagicMock() - session.scalar.return_value = None assert ( retrieval._retriever( flask_app=flask_app, # type: ignore[arg-type] - session=session, - dataset_id="d1", + session=self.orm_session, + dataset_id=missing_id, query="python", top_k=1, all_documents=all_documents, @@ -5432,40 +5511,31 @@ class TestInternalHooksCoverage: == [] ) - external_dataset = SimpleNamespace( - id="ext-ds", + external_dataset = Dataset( + id=external_id, name="External", provider="external", - tenant_id="tenant-1", + tenant_id=tenant_id, retrieval_model={"top_k": 2}, indexing_technique="high_quality", + created_by=creator_id, ) - with ( - patch( - "core.rag.retrieval.dataset_retrieval.ExternalDatasetService.fetch_external_knowledge_retrieval" - ) as mock_external, - ): - session = MagicMock() - session.scalar.return_value = external_dataset - mock_external.return_value = [{"content": "e", "metadata": {}, "score": 0.8, "title": "Ext"}] - retrieval._retriever( - flask_app=flask_app, # type: ignore[arg-type] - session=session, - dataset_id="ext-ds", - query="python", - top_k=1, - all_documents=all_documents, - ) - - economy_dataset = SimpleNamespace( - id="eco-ds", + economy_dataset = Dataset( + id=economy_id, + tenant_id=tenant_id, + name="Economy", provider="dify", retrieval_model={"top_k": 1}, indexing_technique="economy", + created_by=creator_id, ) - high_dataset = SimpleNamespace( - id="hq-ds", + high_dataset = Dataset( + id=high_quality_id, + tenant_id=tenant_id, + name="High quality", provider="dify", + created_by=creator_id, + indexing_technique="high_quality", retrieval_model={ "search_method": "semantic_search", "top_k": 4, @@ -5476,10 +5546,24 @@ class TestInternalHooksCoverage: "reranking_mode": "reranking_model", "weights": {"vector_setting": {}}, }, - indexing_technique="high_quality", ) - session = MagicMock() - session.scalar.side_effect = [economy_dataset, high_dataset] + self.orm_session.add_all([external_dataset, economy_dataset, high_dataset]) + self.orm_session.commit() + with ( + patch( + "core.rag.retrieval.dataset_retrieval.ExternalDatasetService.fetch_external_knowledge_retrieval" + ) as mock_external, + ): + mock_external.return_value = [{"content": "e", "metadata": {}, "score": 0.8, "title": "Ext"}] + retrieval._retriever( + flask_app=flask_app, # type: ignore[arg-type] + session=self.orm_session, + dataset_id=external_id, + query="python", + top_k=1, + all_documents=all_documents, + ) + with ( patch( "core.rag.retrieval.dataset_retrieval.RetrievalService.retrieve", return_value=[_doc(provider="dify")] @@ -5487,16 +5571,16 @@ class TestInternalHooksCoverage: ): retrieval._retriever( flask_app=flask_app, # type: ignore[arg-type] - session=session, - dataset_id="eco-ds", + session=self.orm_session, + dataset_id=economy_id, query="python", top_k=2, all_documents=all_documents, ) retrieval._retriever( flask_app=flask_app, # type: ignore[arg-type] - session=session, - dataset_id="hq-ds", + session=self.orm_session, + dataset_id=high_quality_id, query="python", top_k=2, all_documents=all_documents, @@ -5506,23 +5590,52 @@ class TestInternalHooksCoverage: assert len(all_documents) >= 3 def test_to_dataset_retriever_tool_paths(self, retrieval: DatasetRetrieval) -> None: - dataset_skip_zero = SimpleNamespace( - id="d1", + tenant_id = str(uuid4()) + creator_id = str(uuid4()) + missing_id = str(uuid4()) + skipped_id = str(uuid4()) + available_id = str(uuid4()) + dataset_skip_zero = Dataset( + id=skipped_id, + tenant_id=tenant_id, + name="Empty dataset", provider="dify", - get_total_available_documents=Mock(return_value=0), + indexing_technique="high_quality", + created_by=creator_id, ) - dataset_ok_single = SimpleNamespace( - id="d2", + dataset_ok_single = Dataset( + id=available_id, + tenant_id=tenant_id, + name="Available dataset", provider="dify", - get_total_available_documents=Mock(return_value=2), + indexing_technique="high_quality", + created_by=creator_id, retrieval_model={"top_k": 2, "score_threshold_enabled": True, "score_threshold": 0.1}, ) + self.orm_session.add_all([dataset_skip_zero, dataset_ok_single]) + self.orm_session.add_all( + [ + DatasetDocument( + tenant_id=tenant_id, + dataset_id=available_id, + position=position, + data_source_type="upload_file", + batch="batch-1", + name=f"document-{position}", + created_from="api", + created_by=creator_id, + indexing_status="completed", + enabled=True, + archived=False, + ) + for position in (1, 2) + ] + ) + self.orm_session.commit() single_config = DatasetRetrieveConfigEntity( retrieve_strategy=DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE, metadata_filtering_mode="disabled", ) - session = MagicMock() - session.scalar.side_effect = [None, dataset_skip_zero, dataset_ok_single] with ( patch( "core.tools.utils.dataset_retriever.dataset_retriever_tool.DatasetRetrieverTool.from_dataset", @@ -5530,9 +5643,9 @@ class TestInternalHooksCoverage: ) as mock_single_tool, ): single_tools = retrieval.to_dataset_retriever_tool( - session=session, - tenant_id="tenant-1", - dataset_ids=["missing", "d1", "d2"], + session=self.orm_session, + tenant_id=tenant_id, + dataset_ids=[missing_id, skipped_id, available_id], retrieve_config=single_config, return_resource=True, invoke_from=InvokeFrom.WEB_APP, @@ -5549,13 +5662,11 @@ class TestInternalHooksCoverage: metadata_filtering_mode="disabled", reranking_model=None, ) - session = MagicMock() - session.scalar.return_value = dataset_ok_single with pytest.raises(ValueError, match="Reranking model is required"): retrieval.to_dataset_retriever_tool( - session=session, - tenant_id="tenant-1", - dataset_ids=["d2"], + session=self.orm_session, + tenant_id=tenant_id, + dataset_ids=[available_id], retrieve_config=multiple_config_missing, return_resource=True, invoke_from=InvokeFrom.WEB_APP, @@ -5571,8 +5682,6 @@ class TestInternalHooksCoverage: score_threshold=0.2, reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v3"}, ) - session = MagicMock() - session.scalar.return_value = dataset_ok_single with ( patch( "core.tools.utils.dataset_retriever.dataset_multi_retriever_tool.DatasetMultiRetrieverTool.from_dataset", @@ -5580,9 +5689,9 @@ class TestInternalHooksCoverage: ) as mock_multi_tool, ): multi_tools = retrieval.to_dataset_retriever_tool( - session=session, - tenant_id="tenant-1", - dataset_ids=["d2"], + session=self.orm_session, + tenant_id=tenant_id, + dataset_ids=[available_id], retrieve_config=multiple_config, return_resource=False, invoke_from=InvokeFrom.DEBUGGER, @@ -5602,22 +5711,30 @@ class TestInternalHooksCoverage: assert len(ranked) == 1 assert ranked[0].metadata.get("score") == 0.0 - with patch("core.rag.retrieval.dataset_retrieval.db.session.scalars") as mock_scalars: - mock_scalars.return_value.all.return_value = [] - with pytest.raises(ValueError): - retrieval._automatic_metadata_filter_func( - MagicMock(), - dataset_ids=["d1"], - query="python", - tenant_id="tenant-1", - user_id="user-1", - metadata_model_config=None, # type: ignore[arg-type] - ) + with pytest.raises(ValueError): + retrieval._automatic_metadata_filter_func( + self.orm_session, + dataset_ids=[str(uuid4())], + query="python", + tenant_id=str(uuid4()), + user_id=str(uuid4()), + metadata_model_config=None, # type: ignore[arg-type] + ) - session_scalars = Mock() - session_scalars.all.return_value = [SimpleNamespace(name="author")] + tenant_id = str(uuid4()) + dataset_id = str(uuid4()) + user_id = str(uuid4()) + self.orm_session.add( + DatasetMetadata( + tenant_id=tenant_id, + dataset_id=dataset_id, + type="string", + name="author", + created_by=user_id, + ) + ) + self.orm_session.commit() with ( - patch("core.rag.retrieval.dataset_retrieval.db.session.scalars", return_value=session_scalars), patch.object(retrieval, "_fetch_model_config", return_value=(Mock(), Mock())), patch.object(retrieval, "_get_prompt_template", return_value=(["prompt"], [])), patch.object(retrieval, "_record_usage"), @@ -5627,11 +5744,11 @@ class TestInternalHooksCoverage: with patch.object(retrieval, "_fetch_model_config", return_value=(model_instance, Mock())): assert ( retrieval._automatic_metadata_filter_func( - MagicMock(), - dataset_ids=["d1"], + self.orm_session, + dataset_ids=[dataset_id], query="python", - tenant_id="tenant-1", - user_id="user-1", + tenant_id=tenant_id, + user_id=user_id, metadata_model_config=WorkflowModelConfig(provider="openai", name="gpt", mode="chat"), ) is None diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py index c7774e6836f..14e0f99c699 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py @@ -1,12 +1,11 @@ +from collections.abc import Iterator from uuid import uuid4 import pytest from sqlalchemy import event, inspect -from sqlalchemy.engine import Engine from sqlalchemy.orm import ORMExecuteState, Session, sessionmaker from sqlalchemy.sql import Executable -import core.workflow.nodes.agent_v2.binding_resolver as resolver_module from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver from models.agent import ( Agent, @@ -104,24 +103,24 @@ def _binding( ) -def _bind_factory(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> list[Executable]: +@pytest.fixture +def orm_statements(sqlite_session_factory: sessionmaker[Session]) -> Iterator[list[Executable]]: + """Record statements executed by the service-owned SQLite sessions.""" scalar_statements: list[Executable] = [] - class RecordingSession(Session): - pass - def record_statement(execute_state: ORMExecuteState) -> None: scalar_statements.append(execute_state.statement) - event.listen(RecordingSession, "do_orm_execute", record_statement) - factory = sessionmaker(bind=sqlite_engine, class_=RecordingSession, expire_on_commit=False) - monkeypatch.setattr(resolver_module.session_factory, "create_session", factory) - return scalar_statements + event.listen(sqlite_session_factory.class_, "do_orm_execute", record_statement) + try: + yield scalar_statements + finally: + event.remove(sqlite_session_factory.class_, "do_orm_execute", record_statement) @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_returns_detached_binding_bundle( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session + sqlite_session: Session, ) -> None: ids = _resolve_ids() agent = _agent(tenant_id=ids["tenant_id"]) @@ -138,8 +137,6 @@ def test_binding_resolver_returns_detached_binding_bundle( ) sqlite_session.add(binding) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - bundle = WorkflowAgentBindingResolver().resolve(**ids) assert bundle.binding.id == binding.id @@ -152,7 +149,7 @@ def test_binding_resolver_returns_detached_binding_bundle( @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_uses_active_snapshot_for_roster_agent( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session + sqlite_session: Session, ) -> None: ids = _resolve_ids() agent = _agent( @@ -174,8 +171,6 @@ def test_binding_resolver_uses_active_snapshot_for_roster_agent( ) sqlite_session.add(binding) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - bundle = WorkflowAgentBindingResolver().resolve(**ids) assert bundle.snapshot.id == active_snapshot.id @@ -190,9 +185,8 @@ def test_binding_resolver_uses_active_snapshot_for_roster_agent( ) @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_uses_pinned_snapshot_for_existing_node_execution( - monkeypatch: pytest.MonkeyPatch, - sqlite_engine: Engine, sqlite_session: Session, + orm_statements: list[Executable], binding_type: WorkflowAgentBindingType, scope: AgentScope, source: AgentSource, @@ -217,8 +211,6 @@ def test_binding_resolver_uses_pinned_snapshot_for_existing_node_execution( ) sqlite_session.add(binding) sqlite_session.commit() - scalar_statements = _bind_factory(monkeypatch, sqlite_engine) - bundle = WorkflowAgentBindingResolver().resolve( **ids, binding_id=binding.id, @@ -226,13 +218,13 @@ def test_binding_resolver_uses_pinned_snapshot_for_existing_node_execution( ) assert bundle.snapshot.id == pinned_snapshot.id - assert binding.id in scalar_statements[0].compile().params.values() - assert pinned_snapshot.id in scalar_statements[-1].compile().params.values() + assert binding.id in orm_statements[0].compile().params.values() + assert pinned_snapshot.id in orm_statements[-1].compile().params.values() @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_does_not_fallback_from_an_explicit_empty_snapshot( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session + sqlite_session: Session, ) -> None: ids = _resolve_ids() agent = _agent( @@ -254,8 +246,6 @@ def test_binding_resolver_does_not_fallback_from_an_explicit_empty_snapshot( ) sqlite_session.add(binding) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - with pytest.raises(WorkflowAgentBindingError) as exc_info: WorkflowAgentBindingResolver().resolve(**ids, binding_id=binding.id, snapshot_id="") @@ -282,7 +272,7 @@ def test_binding_resolver_rejects_half_pinned_generation( @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_rejects_unpublished_roster_agent( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session + sqlite_session: Session, ) -> None: ids = _resolve_ids() snapshot_id = str(uuid4()) @@ -304,8 +294,6 @@ def test_binding_resolver_rejects_unpublished_roster_agent( ) sqlite_session.add(binding) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - with pytest.raises(WorkflowAgentBindingError) as exc_info: WorkflowAgentBindingResolver().resolve(**ids) @@ -315,8 +303,6 @@ def test_binding_resolver_rejects_unpublished_roster_agent( @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_requires_publish_provenance_for_active_roster_snapshot( - monkeypatch: pytest.MonkeyPatch, - sqlite_engine: Engine, sqlite_session: Session, ) -> None: ids = _resolve_ids() @@ -352,8 +338,6 @@ def test_binding_resolver_requires_publish_provenance_for_active_roster_snapshot ] ) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - with pytest.raises(WorkflowAgentBindingError) as exc_info: WorkflowAgentBindingResolver().resolve(**ids) assert exc_info.value.error_code == "agent_not_available" @@ -375,9 +359,7 @@ def test_binding_resolver_requires_publish_provenance_for_active_roster_snapshot assert bundle.snapshot.id == snapshot.id -def test_binding_resolver_raises_when_binding_missing(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: - _bind_factory(monkeypatch, sqlite_engine) - +def test_binding_resolver_raises_when_binding_missing() -> None: with pytest.raises(WorkflowAgentBindingError) as exc_info: WorkflowAgentBindingResolver().resolve(**_resolve_ids()) @@ -386,7 +368,7 @@ def test_binding_resolver_raises_when_binding_missing(monkeypatch: pytest.Monkey @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_raises_when_agent_archived( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session + sqlite_session: Session, ) -> None: ids = _resolve_ids() agent = _agent(tenant_id=ids["tenant_id"], status=AgentStatus.ARCHIVED) @@ -400,8 +382,6 @@ def test_binding_resolver_raises_when_agent_archived( ) sqlite_session.add(binding) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - with pytest.raises(WorkflowAgentBindingError) as exc_info: WorkflowAgentBindingResolver().resolve(**ids) @@ -410,7 +390,7 @@ def test_binding_resolver_raises_when_agent_archived( @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) def test_binding_resolver_raises_when_snapshot_missing( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session + sqlite_session: Session, ) -> None: ids = _resolve_ids() agent = _agent(tenant_id=ids["tenant_id"]) @@ -424,8 +404,6 @@ def test_binding_resolver_raises_when_snapshot_missing( ) sqlite_session.add(binding) sqlite_session.commit() - _bind_factory(monkeypatch, sqlite_engine) - with pytest.raises(WorkflowAgentBindingError) as exc_info: WorkflowAgentBindingResolver().resolve(**ids) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py index 80581a4630b..07776187a9e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py @@ -14,7 +14,7 @@ from datetime import UTC, datetime import pytest from sqlalchemy import Engine, event from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session from core.workflow.nodes.agent_v2.file_tenant_validator import UploadFileTenantValidator from extensions.storage.storage_type import StorageType @@ -27,13 +27,6 @@ OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" USER_ID = "33333333-3333-3333-3333-333333333333" -@pytest.fixture(autouse=True) -def _bind_sqlite_session_factory(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: - """Bind the validator's service-owned sessions to the isolated SQLite engine.""" - sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker) - - @pytest.fixture def executed_statements(sqlite_engine: Engine) -> Iterator[list[str]]: statements: list[str] = [] diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py index 193049e5fb9..a1de053a81f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py @@ -1,21 +1,26 @@ import json +from collections.abc import Iterator from contextlib import nullcontext from types import SimpleNamespace from unittest.mock import MagicMock import pytest from agenton.compositor import CompositorSessionSnapshot -from sqlalchemy.orm import Session +from sqlalchemy import Engine, event, func, select +from sqlalchemy.orm import Session, sessionmaker from core.workflow.nodes.agent_v2.session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore +from graphon.enums import WorkflowNodeExecutionStatus from models.agent import ( AgentConfigVersionKind, + AgentHomeSnapshot, AgentWorkingResourceStatus, AgentWorkspace, AgentWorkspaceBinding, AgentWorkspaceOwnerType, ) -from models.workflow import WorkflowNodeExecutionModel +from models.enums import CreatorUserRole +from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService from services.agent_app_sandbox_service import WorkflowAgentSandboxService @@ -34,18 +39,35 @@ def _scope() -> WorkflowAgentSessionScope: ) -def _binding() -> SimpleNamespace: - return SimpleNamespace( - id="binding-1", - workspace_id="workspace-1", - agent_id="agent-1", - base_home_snapshot_id="home-1", - agent_config_version_id="config-1", - agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT, - backend_binding_ref="backend-binding-1", - session_snapshot=None, - pending_form_id=None, - pending_tool_call_id=None, +def _execution_row( + *, + binding_id: str | None = None, + process_data: dict[str, object] | None = None, +) -> WorkflowNodeExecutionModel: + return WorkflowNodeExecutionModel( + id="execution-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + workflow_run_id="run-1", + index=1, + predecessor_node_id=None, + node_execution_id="node-execution-1", + node_id="node-1", + node_type="agent", + title="Agent", + agent_workspace_binding_id=binding_id, + inputs="{}", + process_data=json.dumps(process_data) if process_data is not None else None, + outputs=None, + status=WorkflowNodeExecutionStatus.RUNNING, + error=None, + elapsed_time=0, + execution_metadata=None, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user-1", + finished_at=None, ) @@ -75,6 +97,7 @@ def _binding_row( binding_id: str = "binding-1", workspace_id: str = "workspace-1", status: AgentWorkingResourceStatus = AgentWorkingResourceStatus.ACTIVE, + base_home_snapshot_id: str | None = "home-1", ) -> AgentWorkspaceBinding: return AgentWorkspaceBinding( id=binding_id, @@ -82,14 +105,52 @@ def _binding_row( app_id="app-1", workspace_id=workspace_id, agent_id="agent-1", - base_home_snapshot_id="home-1", + base_home_snapshot_id=base_home_snapshot_id, agent_config_version_id="config-1", agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT, - backend_binding_ref=f"{binding_id}-ref", + backend_binding_ref="backend-binding-1" if binding_id == "binding-1" else f"{binding_id}-ref", status=status, ) +def _home_snapshot() -> AgentHomeSnapshot: + return AgentHomeSnapshot( + id="home-1", + tenant_id="tenant-1", + agent_id="agent-1", + snapshot_ref="home-ref", + status=AgentWorkingResourceStatus.ACTIVE, + ) + + +def _install_backend_client(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + client = MagicMock() + client.create_execution_binding_sync.return_value = SimpleNamespace( + binding_ref="backend-binding-1", + workspace_ref="workspace-1-ref", + ) + monkeypatch.setattr(AgentWorkspaceService, "_client", lambda: nullcontext(client)) + return client + + +@pytest.fixture +def executed_statements(sqlite_engine: Engine) -> Iterator[list[str]]: + statements: list[str] = [] + + def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: + yield statements + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) + + +def _execution_selects(statements: list[str]) -> list[str]: + return [statement for statement in statements if "FROM workflow_node_executions" in statement] + + def test_scope_uses_node_and_workflow_binding_as_workspace_subscope() -> None: owner = _scope().workspace_owner assert owner.owner_type is AgentWorkspaceOwnerType.WORKFLOW_RUN @@ -97,24 +158,20 @@ def test_scope_uses_node_and_workflow_binding_as_workspace_subscope() -> None: assert owner.owner_scope_key == "node-1:workflow-binding-1" -def test_load_existing_scope_reads_the_generation_from_the_persisted_binding( - monkeypatch: pytest.MonkeyPatch, -) -> None: - execution = SimpleNamespace( - agent_workspace_binding_id="binding-1", - process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"}, +def test_load_existing_scope_reads_the_generation_from_the_persisted_binding(sqlite_session: Session) -> None: + sqlite_session.add_all( + [ + _execution_row( + binding_id="binding-1", + process_data={"workflow_agent_binding_id": "workflow-binding-1"}, + ), + _workspace_row(), + _binding_row(), + ] ) - context = MagicMock() - store = WorkflowAgentWorkspaceStore() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(store, "_load_execution_by_identity", MagicMock(return_value=execution)) - get_active = MagicMock(return_value=_binding()) - monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active) + sqlite_session.commit() - scope = store.load_existing_node_execution_scope( + scope = WorkflowAgentWorkspaceStore().load_existing_node_execution_scope( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", @@ -127,25 +184,19 @@ def test_load_existing_scope_reads_the_generation_from_the_persisted_binding( assert scope.workflow_agent_binding_id == "workflow-binding-1" assert scope.agent_id == "agent-1" assert scope.agent_config_snapshot_id == "config-1" - assert get_active.call_args.kwargs["binding_id"] == "binding-1" -def test_load_existing_scope_rejects_unavailable_persisted_binding(monkeypatch: pytest.MonkeyPatch) -> None: - execution = SimpleNamespace( - agent_workspace_binding_id="binding-missing", - process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"}, +def test_load_existing_scope_rejects_unavailable_persisted_binding(sqlite_session: Session) -> None: + sqlite_session.add( + _execution_row( + binding_id="binding-missing", + process_data={"workflow_agent_binding_id": "workflow-binding-1"}, + ) ) - context = MagicMock() - store = WorkflowAgentWorkspaceStore() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(store, "_load_execution_by_identity", MagicMock(return_value=execution)) - monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None)) + sqlite_session.commit() with pytest.raises(AgentWorkspaceNotFoundError, match="participant Binding is unavailable"): - store.load_existing_node_execution_scope( + WorkflowAgentWorkspaceStore().load_existing_node_execution_scope( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", @@ -156,182 +207,128 @@ def test_load_existing_scope_rejects_unavailable_persisted_binding(monkeypatch: @pytest.mark.parametrize("home_snapshot_id", ["home-1", None]) -def test_load_or_create_persists_binding_on_node_execution(monkeypatch, home_snapshot_id: str | None) -> None: - execution = WorkflowNodeExecutionModel( - agent_workspace_binding_id=None, - process_data=json.dumps({"existing": "value"}), - ) - context = MagicMock() - session = context.__enter__.return_value - create = MagicMock(return_value=_binding()) - store = WorkflowAgentWorkspaceStore() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(store, "_load_execution", MagicMock(return_value=execution)) - monkeypatch.setattr(AgentWorkspaceService, "create_binding", create) +def test_load_or_create_persists_binding_on_node_execution( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + home_snapshot_id: str | None, +) -> None: + execution = _execution_row(process_data={"existing": "value"}) + rows: list[object] = [execution] + if home_snapshot_id is not None: + rows.append(_home_snapshot()) + sqlite_session.add_all(rows) + sqlite_session.commit() + _install_backend_client(monkeypatch) - stored = store.load_or_create_node_execution_session(_scope(), home_snapshot_id=home_snapshot_id) + stored = WorkflowAgentWorkspaceStore().load_or_create_node_execution_session( + _scope(), home_snapshot_id=home_snapshot_id + ) - assert stored.binding_id == "binding-1" - assert stored.workspace_id == "workspace-1" + sqlite_session.expire_all() + persisted_execution = sqlite_session.get(WorkflowNodeExecutionModel, execution.id) + assert persisted_execution is not None + assert stored.workspace_id assert stored.backend_binding_ref == "backend-binding-1" - assert execution.agent_workspace_binding_id == "binding-1" - assert execution.process_data_dict == { + assert persisted_execution.agent_workspace_binding_id == stored.binding_id + assert persisted_execution.process_data_dict == { "existing": "value", "workflow_agent_binding_id": "workflow-binding-1", } - assert "agent_workspace_binding_id" not in execution.process_data_dict - assert create.call_args.kwargs["session"] is session - assert create.call_args.kwargs["base_home_snapshot_id"] == home_snapshot_id - session.commit.assert_called_once_with() + assert "agent_workspace_binding_id" not in persisted_execution.process_data_dict + persisted_binding = sqlite_session.get(AgentWorkspaceBinding, stored.binding_id) + assert persisted_binding is not None + assert persisted_binding.base_home_snapshot_id == home_snapshot_id - get_active = MagicMock(return_value=_binding()) - monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active) - session.scalar.return_value = execution resolved = WorkflowAgentSandboxService._resolve_binding( tenant_id="tenant-1", app_id="app-1", workflow_run_id="run-1", node_id="node-1", node_execution_id="execution-1", - session=session, + session=sqlite_session, ) assert resolved.backend_binding_ref == "backend-binding-1" assert resolved.agent_id == "agent-1" assert resolved.agent_config_version_id == "config-1" assert resolved.agent_config_version_kind == "snapshot" - owner_scope = get_active.call_args.kwargs["expected_owner_scope"] - assert owner_scope.owner_scope_key == "node-1:workflow-binding-1" - session.rollback.assert_called_once_with() -def test_load_existing_pointer_rejects_missing_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None: - execution = SimpleNamespace( - agent_workspace_binding_id="binding-1", - process_data=json.dumps({"existing": "value"}), - process_data_dict={"existing": "value"}, - ) - context = MagicMock() - session = context.__enter__.return_value - store = WorkflowAgentWorkspaceStore() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(store, "_load_execution", MagicMock(return_value=execution)) - get_active = MagicMock() - monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active) +def test_load_existing_pointer_rejects_missing_workflow_identity(sqlite_session: Session) -> None: + execution = _execution_row(binding_id="binding-1", process_data={"existing": "value"}) + sqlite_session.add(execution) + sqlite_session.commit() with pytest.raises(AgentWorkspaceNotFoundError, match="caller identity is missing"): - store.load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") + WorkflowAgentWorkspaceStore().load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") - assert json.loads(execution.process_data) == {"existing": "value"} - get_active.assert_not_called() - session.commit.assert_not_called() + sqlite_session.expire(execution) + assert execution.process_data_dict == {"existing": "value"} + assert sqlite_session.scalar(select(func.count()).select_from(AgentWorkspaceBinding)) == 0 -def test_load_existing_pointer_reuses_matching_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None: - original_process_data = json.dumps( - { - "existing": "value", - "workflow_agent_binding_id": "workflow-binding-1", - } - ) - execution = SimpleNamespace( - agent_workspace_binding_id="binding-1", - process_data=original_process_data, - process_data_dict=json.loads(original_process_data), - ) - context = MagicMock() - session = context.__enter__.return_value - store = WorkflowAgentWorkspaceStore() - create = MagicMock() - binding = _binding() - validate_generation = MagicMock() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(store, "_load_execution", MagicMock(return_value=execution)) - monkeypatch.setattr(AgentWorkspaceService, "create_binding", create) - monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding)) - monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation) +def test_load_existing_pointer_reuses_matching_workflow_identity(sqlite_session: Session) -> None: + original_process_data = { + "existing": "value", + "workflow_agent_binding_id": "workflow-binding-1", + } + execution = _execution_row(binding_id="binding-1", process_data=original_process_data) + sqlite_session.add_all([execution, _workspace_row(), _binding_row()]) + sqlite_session.commit() - stored = store.load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") + stored = WorkflowAgentWorkspaceStore().load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") + sqlite_session.expire(execution) assert stored.binding_id == "binding-1" - assert execution.process_data == original_process_data + assert execution.process_data_dict == original_process_data assert "agent_workspace_binding_id" not in execution.process_data_dict - create.assert_not_called() - session.commit.assert_not_called() - validate_generation.assert_called_once_with( - binding, - base_home_snapshot_id="home-1", - agent_config_version_id="config-1", - agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT, - ) + assert sqlite_session.scalar(select(func.count()).select_from(AgentWorkspaceBinding)) == 1 -def test_load_existing_pointer_rejects_conflicting_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None: - execution = SimpleNamespace( - agent_workspace_binding_id="binding-1", - process_data=json.dumps({"workflow_agent_binding_id": "workflow-binding-other"}), - process_data_dict={"workflow_agent_binding_id": "workflow-binding-other"}, +def test_load_existing_pointer_rejects_conflicting_workflow_identity(sqlite_session: Session) -> None: + execution = _execution_row( + binding_id="binding-1", + process_data={"workflow_agent_binding_id": "workflow-binding-other"}, ) - context = MagicMock() - session = context.__enter__.return_value - store = WorkflowAgentWorkspaceStore() - get_active = MagicMock() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(store, "_load_execution", MagicMock(return_value=execution)) - monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active) + sqlite_session.add(execution) + sqlite_session.commit() with pytest.raises(AgentWorkspaceNotFoundError, match="caller identity does not match"): - store.load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") + WorkflowAgentWorkspaceStore().load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") - get_active.assert_not_called() - session.commit.assert_not_called() + assert sqlite_session.scalar(select(func.count()).select_from(AgentWorkspaceBinding)) == 0 -def test_load_or_create_fails_before_binding_create_when_caller_row_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: - context = MagicMock() - session = context.__enter__.return_value - create = MagicMock() +def test_load_or_create_fails_before_binding_create_when_caller_row_is_missing( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + executed_statements: list[str], +) -> None: sleep = MagicMock() - store = WorkflowAgentWorkspaceStore() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) monkeypatch.setattr("core.workflow.nodes.agent_v2.session_store.time.sleep", sleep) - monkeypatch.setattr(session, "scalar", MagicMock(return_value=None)) - monkeypatch.setattr(AgentWorkspaceService, "create_binding", create) with pytest.raises(AgentWorkspaceNotFoundError, match="Workflow node execution caller is unavailable"): - store.load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") + WorkflowAgentWorkspaceStore().load_or_create_node_execution_session(_scope(), home_snapshot_id="home-1") - assert session.scalar.call_count == 60 + assert len(_execution_selects(executed_statements)) == 60 assert sleep.call_count == 59 - create.assert_not_called() - session.commit.assert_not_called() + assert sqlite_session.scalar(select(func.count()).select_from(AgentWorkspaceBinding)) == 0 -def test_load_existing_scope_waits_for_caller_row_to_become_visible(monkeypatch: pytest.MonkeyPatch) -> None: - execution = SimpleNamespace(agent_workspace_binding_id=None) - context = MagicMock() - session = context.__enter__.return_value - session.scalar.side_effect = [None, None, execution] +def test_load_existing_scope_waits_for_caller_row_to_become_visible( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], + executed_statements: list[str], +) -> None: sleep = MagicMock() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) + + def make_caller_visible(_seconds: float) -> None: + if sleep.call_count == 2: + with sqlite_session_factory() as observer: + observer.add(_execution_row()) + observer.commit() + + sleep.side_effect = make_caller_visible monkeypatch.setattr("core.workflow.nodes.agent_v2.session_store.time.sleep", sleep) scope = WorkflowAgentWorkspaceStore().load_existing_node_execution_scope( @@ -344,24 +341,27 @@ def test_load_existing_scope_waits_for_caller_row_to_become_visible(monkeypatch: ) assert scope is None - assert session.scalar.call_count == 3 + assert len(_execution_selects(executed_statements)) == 3 assert sleep.call_count == 2 -def test_save_snapshot_targets_binding(monkeypatch: pytest.MonkeyPatch) -> None: - save = MagicMock() - monkeypatch.setattr(AgentWorkspaceService, "save_binding_session_snapshot", save) +def test_save_snapshot_targets_binding(sqlite_session: Session) -> None: + binding = _binding_row() + sqlite_session.add_all([_workspace_row(), binding]) + sqlite_session.commit() snapshot = CompositorSessionSnapshot(layers=[]) - WorkflowAgentWorkspaceStore().save_active_snapshot(scope=_scope(), binding_id="binding-1", snapshot=snapshot) + WorkflowAgentWorkspaceStore().save_active_snapshot( + scope=_scope(), + binding_id=binding.id, + snapshot=snapshot, + ) - assert save.call_args.kwargs["binding_id"] == "binding-1" + sqlite_session.expire(binding) + assert binding.session_snapshot == snapshot.model_dump_json() -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) -def test_retire_workflow_run_only_retires_matching_tenant_and_app( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: +def test_retire_workflow_run_only_retires_matching_tenant_and_app(sqlite_session: Session) -> None: matching = _workspace_row() other_tenant = _workspace_row(workspace_id="workspace-other-tenant", tenant_id="tenant-2") other_app = _workspace_row( @@ -371,10 +371,6 @@ def test_retire_workflow_run_only_retires_matching_tenant_and_app( ) sqlite_session.add_all([matching, other_tenant, other_app]) sqlite_session.commit() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: nullcontext(sqlite_session), - ) workspace_ids = WorkflowAgentWorkspaceStore().retire_workflow_run( tenant_id="tenant-1", @@ -382,24 +378,18 @@ def test_retire_workflow_run_only_retires_matching_tenant_and_app( workflow_run_id="run-1", ) + sqlite_session.expire_all() assert matching.status is AgentWorkingResourceStatus.RETIRED assert other_tenant.status is AgentWorkingResourceStatus.ACTIVE assert other_app.status is AgentWorkingResourceStatus.ACTIVE assert workspace_ids == [matching.id] -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) -def test_retire_workflow_run_transitions_active_workspace( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: +def test_retire_workflow_run_transitions_active_workspace(sqlite_session: Session) -> None: workspace = _workspace_row() binding = _binding_row() sqlite_session.add_all([workspace, binding]) sqlite_session.commit() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: nullcontext(sqlite_session), - ) workspace_ids = WorkflowAgentWorkspaceStore().retire_workflow_run( tenant_id="tenant-1", @@ -407,22 +397,16 @@ def test_retire_workflow_run_transitions_active_workspace( workflow_run_id="run-1", ) + sqlite_session.expire_all() assert workspace.status is AgentWorkingResourceStatus.RETIRED assert binding.status is AgentWorkingResourceStatus.RETIRED assert workspace_ids == [workspace.id] -def test_retire_workflow_run_returns_existing_retired_workspace(monkeypatch: pytest.MonkeyPatch) -> None: +def test_retire_workflow_run_returns_existing_retired_workspace(sqlite_session: Session) -> None: workspace = _workspace_row(status=AgentWorkingResourceStatus.RETIRED) - context = MagicMock() - session = context.__enter__.return_value - session.scalars.return_value.all.return_value = [workspace] - retire = MagicMock() - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.session_store.session_factory.create_session", - lambda: context, - ) - monkeypatch.setattr(AgentWorkspaceService, "retire_workspace", retire) + sqlite_session.add(workspace) + sqlite_session.commit() workspace_ids = WorkflowAgentWorkspaceStore().retire_workflow_run( tenant_id="tenant-1", @@ -430,5 +414,6 @@ def test_retire_workflow_run_returns_existing_retired_workspace(monkeypatch: pyt workflow_run_id="run-1", ) - retire.assert_not_called() + sqlite_session.expire(workspace) + assert workspace.status is AgentWorkingResourceStatus.RETIRED assert workspace_ids == [workspace.id] diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index 97182c011ad..f5361f9cc71 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -1,15 +1,27 @@ import json -from types import SimpleNamespace +from datetime import UTC, datetime from unittest.mock import Mock import pytest +from sqlalchemy.orm import Session from core.workflow.nodes.agent_v2.validators import ( WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator, ) -from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding +from extensions.storage.storage_type import StorageType +from models.agent import ( + Agent, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig +from models.enums import CreatorUserRole +from models.model import UploadFile from models.workflow import Workflow @@ -28,7 +40,9 @@ def _binding(node_job: WorkflowNodeJobConfig) -> WorkflowAgentNodeBinding: tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", + workflow_version="draft", node_id="agent-node", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=node_job, @@ -36,7 +50,14 @@ def _binding(node_job: WorkflowNodeJobConfig) -> WorkflowAgentNodeBinding: def _agent() -> Agent: - return Agent(id="agent-1", tenant_id="tenant-1", name="Agent", status=AgentStatus.ACTIVE) + return Agent( + id="agent-1", + tenant_id="tenant-1", + name="Agent", + status=AgentStatus.ACTIVE, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + ) def _snapshot() -> AgentConfigSnapshot: @@ -133,15 +154,49 @@ def test_historical_agent_version_two_is_not_validated_as_dify_agent() -> None: session.scalar.assert_not_called() -def test_publish_validation_accepts_upstream_previous_output_ref(): +def _persist_validation_scope( + session: Session, + *, + node_job: WorkflowNodeJobConfig, + binding: WorkflowAgentNodeBinding | None = None, + agent: Agent | None = None, + snapshot: AgentConfigSnapshot | None = None, + extras: tuple[object, ...] = (), +) -> tuple[WorkflowAgentNodeBinding, Agent, AgentConfigSnapshot]: + binding = binding or _binding(node_job) + agent = agent or _agent() + snapshot = snapshot or _snapshot() + session.add_all([binding, agent, snapshot, *extras]) + session.commit() + return binding, agent, snapshot + + +def _upload_file(*, file_id: str = "file-1", tenant_id: str = "tenant-1") -> UploadFile: + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.LOCAL, + key="files/benchmark.txt", + name="benchmark.txt", + size=10, + extension="txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user-1", + created_at=datetime.now(UTC), + used=True, + ) + upload_file.id = file_id + return upload_file + + +def test_publish_validation_accepts_upstream_previous_output_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow( _graph( [ @@ -153,34 +208,49 @@ def test_publish_validation_accepts_upstream_previous_output_ref(): ) -def test_publish_validation_uses_active_snapshot_for_roster_agent(): +def test_publish_validation_uses_active_snapshot_for_roster_agent(sqlite_session: Session): node_job = WorkflowNodeJobConfig() binding = _binding(node_job) binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT binding.current_snapshot_id = "old-snapshot" agent = _agent() + agent.scope = AgentScope.ROSTER + agent.source = AgentSource.ROSTER agent.active_config_snapshot_id = "active-snapshot" + agent.active_config_has_model = True + agent.active_config_is_published = True snapshot = _snapshot() snapshot.id = "active-snapshot" - session = Mock() - session.scalar.side_effect = [binding, agent, snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, binding=binding, agent=agent, snapshot=snapshot) WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_non_upstream_previous_output_ref(): +def test_publish_validation_rejects_unpublished_roster_agent(sqlite_session: Session): + binding = _binding(WorkflowNodeJobConfig()) + binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT + sqlite_session.add(binding) + sqlite_session.commit() + + with pytest.raises(WorkflowAgentNodeValidationError, match="unpublished roster agent"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=sqlite_session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_rejects_non_upstream_previous_output_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "later-node", "output": "text"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow( _graph( [ @@ -192,38 +262,33 @@ def test_publish_validation_rejects_non_upstream_previous_output_ref(): ) -def test_draft_validation_allows_unbound_agent_node(): - session = Mock() - session.scalar.return_value = None - +def test_draft_validation_allows_unbound_agent_node(sqlite_session: Session): WorkflowAgentNodeValidator.validate_draft_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_draft_validation_allows_missing_previous_node(): +def test_draft_validation_allows_missing_previous_node(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) WorkflowAgentNodeValidator.validate_draft_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_draft_validation_allows_non_upstream_previous_output_ref(): +def test_draft_validation_allows_non_upstream_previous_output_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "later-node", "output": "text"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) WorkflowAgentNodeValidator.validate_draft_workflow( - session=session, + session=sqlite_session, workflow=_workflow( _graph( [ @@ -253,30 +318,26 @@ def test_draft_validation_allows_missing_agent_soul_model(): ) -def test_draft_validation_rejects_incomplete_previous_output_ref(): +def test_draft_validation_rejects_incomplete_previous_output_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({"previous_node_output_refs": [{"selector": ["previous-node"]}]}) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete previous node output ref"): WorkflowAgentNodeValidator.validate_draft_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_requires_binding(): - session = Mock() - session.scalar.return_value = None - +def test_publish_validation_requires_binding(sqlite_session: Session): with pytest.raises(WorkflowAgentNodeValidationError, match="requires a binding"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_duplicate_output_names(): +def test_publish_validation_rejects_duplicate_output_names(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( { "declared_outputs": [ @@ -285,17 +346,16 @@ def test_publish_validation_rejects_duplicate_output_names(): ] } ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate output name"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_missing_agent_soul_model(): +def test_publish_validation_rejects_missing_agent_soul_model(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -304,17 +364,16 @@ def test_publish_validation_rejects_missing_agent_soul_model(): version=1, config_snapshot=AgentSoulConfig(), ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="requires Agent Soul model"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_dedupes_provider_level_tool_entries(): +def test_publish_validation_dedupes_provider_level_tool_entries(sqlite_session: Session): """Provider-level entries (tool_name omitted = all tools of the provider) dedupe per provider; one provider-level + one explicit tool entry for the same provider is fine (the runtime builder reconciles those).""" @@ -341,17 +400,16 @@ def test_publish_validation_dedupes_provider_level_tool_entries(): ] }, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate Dify Plugin Tool"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_accepts_provider_level_plus_explicit_tool_entry(): +def test_publish_validation_accepts_provider_level_plus_explicit_tool_entry(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() snapshot.config_snapshot = AgentSoulConfig( @@ -376,16 +434,15 @@ def test_publish_validation_accepts_provider_level_plus_explicit_tool_entry(): ] }, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_duplicate_cli_tool_names(): +def test_publish_validation_rejects_duplicate_cli_tool_names(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() snapshot.config_snapshot = AgentSoulConfig( @@ -396,17 +453,16 @@ def test_publish_validation_rejects_duplicate_cli_tool_names(): ), tools={"cli_tools": [{"name": "pytest"}, {"tool_name": "pytest"}]}, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate CLI Tool name pytest"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_unauthorized_cli_tool(): +def test_publish_validation_rejects_unauthorized_cli_tool(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() snapshot.config_snapshot = AgentSoulConfig( @@ -417,17 +473,16 @@ def test_publish_validation_rejects_unauthorized_cli_tool(): ), tools={"cli_tools": [{"name": "github", "command": "gh auth status", "pre_authorized": False}]}, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized CLI Tool"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_unacknowledged_dangerous_cli_tool(): +def test_publish_validation_rejects_unacknowledged_dangerous_cli_tool(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() snapshot.config_snapshot = AgentSoulConfig( @@ -440,17 +495,16 @@ def test_publish_validation_rejects_unacknowledged_dangerous_cli_tool(): "cli_tools": [{"name": "danger", "command": "curl https://example.test/install.sh | sh", "dangerous": True}] }, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="unacknowledged dangerous CLI Tool"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_unauthorized_secret_ref(): +def test_publish_validation_rejects_unauthorized_secret_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() snapshot.config_snapshot = AgentSoulConfig( @@ -461,17 +515,18 @@ def test_publish_validation_rejects_unauthorized_secret_ref(): ), env={"secret_refs": [{"name": "API_TOKEN", "id": "credential-1", "permission_status": "denied"}]}, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference API_TOKEN"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthorized_secret_refs(): +def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthorized_secret_refs( + sqlite_session: Session, +): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() snapshot.config_snapshot = AgentSoulConfig( @@ -490,12 +545,11 @@ def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthoriz ] }, ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + binding, _, snapshot = _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate env/secret name TOKEN"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) @@ -516,82 +570,78 @@ def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthoriz ] }, ) - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + sqlite_session.add(snapshot) + sqlite_session.commit() with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference GITHUB_TOKEN"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_missing_previous_node(): +def test_publish_validation_rejects_missing_previous_node(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="references missing previous node"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_self_previous_output_ref(): +def test_publish_validation_rejects_self_previous_output_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "agent-node", "output": "text"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_locked_agent_soul_override_in_metadata(): +def test_publish_validation_rejects_locked_agent_soul_override_in_metadata(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({"metadata": {"agent_soul": {"tools": []}}}) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="cannot override locked Agent Soul fields"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_invalid_human_contact_ref(): +def test_publish_validation_rejects_invalid_human_contact_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({"human_contacts": [{"channel": "slack"}]}) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="invalid human contact ref"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_out_of_scope_human_contact_ref(): +def test_publish_validation_rejects_out_of_scope_human_contact_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( {"human_contacts": [{"contact_id": "human-1", "tenant_id": "other-tenant", "channel": "slack"}]} ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="out-of-scope human contact"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_accepts_tenant_scoped_file_ref(): +def test_publish_validation_accepts_tenant_scoped_file_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate( { "declared_outputs": [ @@ -607,40 +657,33 @@ def test_publish_validation_accepts_tenant_scoped_file_ref(): ] } ) - session = Mock() - session.scalar.side_effect = [ - _binding(node_job), - _agent(), - _snapshot(), - SimpleNamespace(id="file-1", tenant_id="tenant-1"), - ] + _persist_validation_scope(sqlite_session, node_job=node_job, extras=(_upload_file(),)) WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) -def test_publish_validation_rejects_missing_file_ref(): +def test_publish_validation_rejects_missing_file_ref(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({"metadata": {"file_refs": [{"upload_file_id": "missing-file"}]}}) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot(), None] + _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="missing or out-of-scope metadata file ref"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets( monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ): dataset_id = "550e8400-e29b-41d4-a716-446655440000" node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot_with_knowledge_dataset(dataset_id) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) captured = {} @@ -655,52 +698,44 @@ def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets( with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=sqlite_session, workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), ) assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"} -def test_publish_validation_accepts_tool_node_agentic_manual_mode(): - session = Mock() - +def test_publish_validation_accepts_tool_node_agentic_manual_mode(unbound_session: Session): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=unbound_session, workflow=_workflow(_tool_graph({"agentic_mode": {"state": "manual"}})), ) -def test_publish_validation_accepts_tool_node_agentic_parameter_draft(): - session = Mock() - +def test_publish_validation_accepts_tool_node_agentic_parameter_draft(unbound_session: Session): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=unbound_session, workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "parameter_draft": {"query": "x"}}})), ) -def test_publish_validation_rejects_incomplete_tool_node_agentic_config(): - session = Mock() - +def test_publish_validation_rejects_incomplete_tool_node_agentic_config(unbound_session: Session): with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=unbound_session, workflow=_workflow(_tool_graph({"agentic_mode": True})), ) with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=unbound_session, workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "complete": False}})), ) -def test_publish_validation_rejects_unauthorized_tool_node_agentic_config(): - session = Mock() - +def test_publish_validation_rejects_unauthorized_tool_node_agentic_config(unbound_session: Session): with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized agentic mode config"): WorkflowAgentNodeValidator.validate_published_workflow( - session=session, + session=unbound_session, workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})), ) diff --git a/api/tests/unit_tests/events/test_app_event_signals.py b/api/tests/unit_tests/events/test_app_event_signals.py index 35471eab0de..cde404407a2 100644 --- a/api/tests/unit_tests/events/test_app_event_signals.py +++ b/api/tests/unit_tests/events/test_app_event_signals.py @@ -19,7 +19,7 @@ from services.app_service import AppService def _mock_deps() -> Iterator[None]: with ( patch("services.app_service.BillingService"), - patch("services.app_service.FeatureService"), + patch("services.app_service.SystemFeatureService"), patch("services.app_service.EnterpriseService"), patch("services.app_service.remove_app_and_related_data_task"), ): diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index 191f13cf7e3..bcca4ab5d0f 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -2,7 +2,7 @@ import json from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from uuid import uuid4 import httpx @@ -44,6 +44,7 @@ from services.billing_portal_service import BillingPortalService from services.billing_service import BillingService from services.compliance_download_service import ComplianceDownloadService from services.enterprise.enterprise_service import WebAppSettings +from services.entities.mail_entities import InnerMailMessage from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError from services.init_validation_service import InvalidInitializationPasswordError from services.partner_tenant_binding_service import PartnerTenantBindingService @@ -451,6 +452,59 @@ def test_build_application_services_wires_data_source_api_key_auth( assert isinstance(services.data_source_api_key_auth, DataSourceApiKeyAuthService) +@pytest.mark.parametrize( + ("substitutions", "expected_substitutions"), + [ + pytest.param({"name": "Ada"}, {"name": "Ada"}, id="configured"), + pytest.param(None, {}, id="omitted-or-null"), + ], +) +def test_build_application_services_wires_inner_mail_dispatcher( + sqlite_session_factory: sessionmaker[Session], + substitutions: dict[str, object] | None, + expected_substitutions: dict[str, object], +) -> None: + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.COMMUNITY, + initialization_password="", + redis=MagicMock(spec=RedisClientWrapper), + ) + message = InnerMailMessage( + recipients=("one@example.com", "two@example.com"), + subject="Subject", + body="Body", + substitutions=substitutions, + ) + + with patch("tasks.mail_inner_task.send_inner_email_task.delay") as delay: + services.inner_mail.send(message) + + delay.assert_called_once_with( + to=["one@example.com", "two@example.com"], + subject="Subject", + body="Body", + substitutions=expected_substitutions, + ) + + +def test_build_application_services_uses_passed_edition_for_webapp_auth( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], +) -> None: + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + + with patch("extensions.ext_application_services.DeploymentWebPassportAuthGateway") as auth_gateway: + ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.ENTERPRISE, + initialization_password="", + redis=MagicMock(spec=RedisClientWrapper), + ) + + assert auth_gateway.call_args.kwargs["webapp_auth_enabled"] is True + + def test_build_application_services_wires_trial_app_usage( sqlite_session_factory: sessionmaker[Session], ) -> None: @@ -480,7 +534,7 @@ def test_build_application_services_adapts_enterprise_webapp_access_mode( sqlite_session_factory: sessionmaker[Session], ) -> None: with ( - patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True), + patch("extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True), patch( "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", return_value=SimpleNamespace(access_mode="private_all"), @@ -517,7 +571,7 @@ def test_build_application_services_maps_known_enterprise_errors( enterprise_error: Exception, ) -> None: with ( - patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True), + patch("extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True), patch( "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", side_effect=enterprise_error, @@ -540,7 +594,7 @@ def test_build_application_services_maps_invalid_access_mode_to_unavailable( sqlite_session_factory: sessionmaker[Session], ) -> None: with ( - patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True), + patch("extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True), patch( "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", return_value=SimpleNamespace(access_mode="invalid"), @@ -564,7 +618,7 @@ def test_build_application_services_does_not_hide_unknown_enterprise_errors( ) -> None: failure = TypeError("adapter bug") with ( - patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True), + patch("extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True), patch( "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", side_effect=failure, @@ -588,7 +642,7 @@ def test_build_application_services_wires_webapp_permission( ) -> None: with ( patch( - "extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True + "extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True ) as enabled, patch( "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", @@ -610,7 +664,12 @@ def test_build_application_services_wires_webapp_permission( assert requires_permission is True assert allowed is False - enabled.assert_called_once_with() + enabled.assert_has_calls( + [ + call(deployment_edition=DeploymentEdition.COMMUNITY), + call(deployment_edition=DeploymentEdition.COMMUNITY), + ] + ) get_access_mode.assert_called_once_with("app-1") is_user_allowed.assert_called_once_with("user-1", "app-1") diff --git a/api/tests/unit_tests/libs/test_workspace_permission.py b/api/tests/unit_tests/libs/test_workspace_permission.py index 9afbcffef9e..ca410e666ac 100644 --- a/api/tests/unit_tests/libs/test_workspace_permission.py +++ b/api/tests/unit_tests/libs/test_workspace_permission.py @@ -25,18 +25,12 @@ class TestWorkspacePermissionHelper: # EnterpriseService should NOT be called in community edition mock_enterprise_service.WorkspacePermissionService.get_permission.assert_not_called() - @patch("libs.workspace_permission.FeatureService") - def test_community_edition_allows_transfer(self, mock_feature_service, config_overrides): + def test_community_edition_allows_transfer(self, config_overrides): """Community edition should check billing plan but not call enterprise service.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - mock_features = Mock() - mock_features.is_allow_transfer_workspace = True - mock_feature_service.get_features.return_value = mock_features # Should not raise - check_workspace_owner_transfer_permission("test-workspace-id") - - mock_feature_service.get_features.assert_called_once_with("test-workspace-id", exclude_vector_space=True) + check_workspace_owner_transfer_permission("test-workspace-id", owner_transfer_allowed=True) @patch("libs.workspace_permission.EnterpriseService") def test_enterprise_blocks_invite_when_disabled(self, mock_enterprise_service, config_overrides): @@ -67,57 +61,38 @@ class TestWorkspacePermissionHelper: mock_enterprise_service.WorkspacePermissionService.get_permission.assert_called_once_with("test-workspace-id") @patch("libs.workspace_permission.EnterpriseService") - @patch("libs.workspace_permission.FeatureService") - def test_billing_plan_blocks_transfer(self, mock_feature_service, mock_enterprise_service, config_overrides): + def test_billing_plan_blocks_transfer(self, mock_enterprise_service, config_overrides): """SANDBOX billing plan should block owner transfer before checking enterprise policy.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_features = Mock() - mock_features.is_allow_transfer_workspace = False # SANDBOX plan - mock_feature_service.get_features.return_value = mock_features - with pytest.raises(Forbidden, match="Your current plan does not allow workspace ownership transfer"): - check_workspace_owner_transfer_permission("test-workspace-id") + check_workspace_owner_transfer_permission("test-workspace-id", owner_transfer_allowed=False) # Enterprise service should NOT be called since billing plan already blocks mock_enterprise_service.WorkspacePermissionService.get_permission.assert_not_called() @patch("libs.workspace_permission.EnterpriseService") - @patch("libs.workspace_permission.FeatureService") - def test_enterprise_blocks_transfer_when_disabled( - self, mock_feature_service, mock_enterprise_service, config_overrides - ): + def test_enterprise_blocks_transfer_when_disabled(self, mock_enterprise_service, config_overrides): """Enterprise edition should block transfer when workspace policy is False.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_features = Mock() - mock_features.is_allow_transfer_workspace = True # Billing plan allows - mock_feature_service.get_features.return_value = mock_features - mock_permission = Mock() mock_permission.allow_owner_transfer = False # Workspace policy blocks mock_enterprise_service.WorkspacePermissionService.get_permission.return_value = mock_permission with pytest.raises(Forbidden, match="Workspace policy prohibits ownership transfer"): - check_workspace_owner_transfer_permission("test-workspace-id") + check_workspace_owner_transfer_permission("test-workspace-id", owner_transfer_allowed=True) mock_enterprise_service.WorkspacePermissionService.get_permission.assert_called_once_with("test-workspace-id") @patch("libs.workspace_permission.EnterpriseService") - @patch("libs.workspace_permission.FeatureService") - def test_enterprise_allows_transfer_when_both_enabled( - self, mock_feature_service, mock_enterprise_service, config_overrides - ): + def test_enterprise_allows_transfer_when_both_enabled(self, mock_enterprise_service, config_overrides): """Enterprise edition should allow transfer when both billing and workspace policy allow.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_features = Mock() - mock_features.is_allow_transfer_workspace = True # Billing plan allows - mock_feature_service.get_features.return_value = mock_features - mock_permission = Mock() mock_permission.allow_owner_transfer = True # Workspace policy allows mock_enterprise_service.WorkspacePermissionService.get_permission.return_value = mock_permission # Should not raise - check_workspace_owner_transfer_permission("test-workspace-id") + check_workspace_owner_transfer_permission("test-workspace-id", owner_transfer_allowed=True) mock_enterprise_service.WorkspacePermissionService.get_permission.assert_called_once_with("test-workspace-id") diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 5c058c42b10..4d691214d2b 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -1660,3 +1660,60 @@ class TestChildChunkSessionAccessors: assert child_chunk.dataset(session=sqlite_session) is None assert child_chunk.document(session=sqlite_session) is None assert child_chunk.segment(session=sqlite_session) is None + + +class TestDocumentSegmentNeighborAccessors: + """Regression coverage for ``DocumentSegment.previous_segment`` and ``next_segment`` refactored + to take a caller-provided session. + + These were ``@property`` accessors reaching for the global ``db.session`` internally; they are now + plain methods accepting a ``Session`` explicitly. + """ + + def test_accessors_resolve_neighboring_segments_via_caller_session(self, sqlite_session: Session): + dataset = _make_dataset(dataset_id=str(uuid4()), tenant_id=str(uuid4())) + document = _make_document(document_id=str(uuid4()), dataset_id=dataset.id, tenant_id=dataset.tenant_id) + segments = _make_segments(document, [0, 0, 0]) + sqlite_session.add_all([dataset, document, *segments]) + sqlite_session.flush() + + middle_segment = segments[1] + assert middle_segment.previous_segment(session=sqlite_session) is segments[0] + assert middle_segment.next_segment(session=sqlite_session) is segments[2] + + def test_accessors_return_none_when_neighbors_are_absent(self, sqlite_session: Session): + dataset = _make_dataset(dataset_id=str(uuid4()), tenant_id=str(uuid4())) + document = _make_document(document_id=str(uuid4()), dataset_id=dataset.id, tenant_id=dataset.tenant_id) + segment = _make_segments(document, [0])[0] + sqlite_session.add_all([dataset, document, segment]) + sqlite_session.flush() + + assert segment.previous_segment(session=sqlite_session) is None + assert segment.next_segment(session=sqlite_session) is None + + +class TestAppDatasetJoinSessionAccessors: + """Regression coverage for ``AppDatasetJoin.app`` refactored to take a caller-provided session. + + ``AppDatasetJoin.app`` was an ``@property`` accessor reaching for the global ``db.session`` internally; + it is now a plain method accepting a ``Session`` explicitly. + """ + + def test_app_accessor_resolves_app_via_caller_session(self, sqlite_session: Session): + app = _make_app(app_id=str(uuid4())) + join = AppDatasetJoin( + app_id=app.id, + dataset_id=str(uuid4()), + ) + sqlite_session.add_all([app, join]) + sqlite_session.flush() + + assert join.app(session=sqlite_session) is app + + def test_app_accessor_returns_none_when_app_absent(self, sqlite_session: Session): + join = AppDatasetJoin( + app_id=str(uuid4()), + dataset_id=str(uuid4()), + ) + + assert join.app(session=sqlite_session) is None diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 36b9a2e0605..b76a91281a7 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -835,14 +835,8 @@ project-excludes = [ "services/test_duplicate_document_indexing_task_proxy.py", "services/test_export_app_messages.py", "services/test_external_dataset_service.py", - "services/test_feature_service_app_dsl_version.py", - "services/test_feature_service_enable_app_deploy.py", "services/test_feature_service_human_input_email_delivery.py", - "services/test_feature_service_learn_app.py", - "services/test_feature_service_licensed_seats.py", - "services/test_feature_service_trial_models.py", "services/test_feature_service_vector_space.py", - "services/test_feature_service_webapp_public_access.py", "services/test_feedback_service.py", "services/test_file_service.py", "services/test_human_input_delivery_test_service.py", diff --git a/api/tests/unit_tests/repositories/test_web_passport_repository.py b/api/tests/unit_tests/repositories/test_web_passport_repository.py new file mode 100644 index 00000000000..6c5a66d5528 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_web_passport_repository.py @@ -0,0 +1,256 @@ +from collections.abc import Callable +from unittest.mock import MagicMock +from uuid import NAMESPACE_URL, uuid5 + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from models.enums import CustomizeTokenStrategy, EndUserType +from models.model import App, AppMode, EndUser, IconType, Site +from repositories.web_passport_repository import WebPassportRepository + + +def _stable_uuid(value: str) -> str: + return str(uuid5(NAMESPACE_URL, value)) + + +def _persist_webapp( + session: Session, + *, + app_code: str = "code", + identity: str | None = None, + enable_site: bool = True, +) -> tuple[App, Site]: + identity = identity or app_code + app = App( + id=_stable_uuid(f"app:{identity}"), + tenant_id=_stable_uuid(f"tenant:{identity}"), + name="Web App", + mode=AppMode.CHAT, + icon_type=IconType.EMOJI, + icon="chat", + icon_background="#FFFFFF", + enable_site=enable_site, + enable_api=False, + ) + site = Site( + id=_stable_uuid(f"site:{identity}"), + app_id=app.id, + title="Web App Site", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + code=app_code, + ) + session.add_all([app, site]) + session.commit() + return app, site + + +def _repository( + session_factory: sessionmaker[Session], + *, + generate_session_id: Callable[[], str] | None = None, +) -> WebPassportRepository: + return WebPassportRepository( + session_factory=session_factory, + generate_session_id=generate_session_id or (lambda: "generated-session"), + ) + + +def test_get_active_web_app_returns_detached_record( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + app, site = _persist_webapp(sqlite_session) + repository = _repository(sqlite_session_factory) + + record = repository.get_active_web_app("code") + + assert record is not None + assert (record.site_id, record.app_id, record.tenant_id, record.app_code) == ( + site.id, + app.id, + app.tenant_id, + "code", + ) + + +def test_get_active_web_app_rejects_disabled_app( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp(sqlite_session, enable_site=False) + repository = _repository(sqlite_session_factory) + + assert repository.get_active_web_app("code") is None + + +def test_get_active_web_app_accepts_duplicate_site_codes( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + first_app, _ = _persist_webapp(sqlite_session, app_code="duplicate", identity="first") + second_app, _ = _persist_webapp(sqlite_session, app_code="duplicate", identity="second") + repository = _repository(sqlite_session_factory) + + record = repository.get_active_web_app("duplicate") + + assert record is not None + assert record.app_id in {first_app.id, second_app.id} + + +def test_is_web_app_active_revalidates_site_code( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _, site = _persist_webapp(sqlite_session) + repository = _repository(sqlite_session_factory) + record = repository.get_active_web_app("code") + assert record is not None + assert repository.is_web_app_active(record) is True + + site.code = "reset-code" + sqlite_session.commit() + + assert repository.is_web_app_active(record) is False + + +def test_resolve_standard_end_user_revalidates_app_in_transaction( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + app, _ = _persist_webapp(sqlite_session) + repository = _repository(sqlite_session_factory) + record = repository.get_active_web_app("code") + assert record is not None + + app.enable_site = False + sqlite_session.commit() + + resolution = repository.resolve_standard_end_user(record, "session") + + assert resolution.app_active is False + assert resolution.end_user is None + assert sqlite_session.scalar(select(EndUser).where(EndUser.session_id == "session")) is None + + +def test_resolve_standard_end_user_reuses_or_creates_within_webapp( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp(sqlite_session, app_code="one") + _persist_webapp(sqlite_session, app_code="two") + repository = _repository(sqlite_session_factory) + app_one = repository.get_active_web_app("one") + app_two = repository.get_active_web_app("two") + assert app_one is not None + assert app_two is not None + + created = repository.resolve_standard_end_user(app_one, "shared-session") + reused = repository.resolve_standard_end_user(app_one, "shared-session") + other_app = repository.resolve_standard_end_user(app_two, "shared-session") + assert created.app_active is True + assert created.end_user is not None + assert reused.end_user == created.end_user + assert other_app.end_user is not None + assert other_app.end_user != created.end_user + + sqlite_session.expire_all() + persisted = sqlite_session.scalar(select(EndUser).where(EndUser.id == created.end_user.id)) + assert persisted is not None + assert persisted.type == EndUserType.BROWSER + assert persisted.app_id == app_one.app_id + + +def test_resolve_authenticated_end_user_prefers_session_identity( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp(sqlite_session) + repository = _repository(sqlite_session_factory) + app = repository.get_active_web_app("code") + assert app is not None + existing = repository.resolve_standard_end_user(app, "existing-session") + assert existing.end_user is not None + + resolution = repository.resolve_authenticated_end_user( + app, + end_user_id=existing.end_user.id, + session_id="authenticated-session", + ) + + assert resolution.app_active is True + assert resolution.end_user is not None + assert resolution.end_user != existing.end_user + persisted = sqlite_session.scalar(select(EndUser).where(EndUser.id == resolution.end_user.id)) + assert persisted is not None + assert persisted.session_id == "authenticated-session" + + +def test_resolve_authenticated_end_user_scopes_id_to_webapp( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp(sqlite_session, app_code="one") + _persist_webapp(sqlite_session, app_code="two") + repository = _repository(sqlite_session_factory) + app_one = repository.get_active_web_app("one") + app_two = repository.get_active_web_app("two") + assert app_one is not None + assert app_two is not None + created = repository.resolve_standard_end_user(app_one, "session") + assert created.end_user is not None + + same_app = repository.resolve_authenticated_end_user( + app_one, + end_user_id=created.end_user.id, + session_id=None, + ) + other_app = repository.resolve_authenticated_end_user( + app_two, + end_user_id=created.end_user.id, + session_id=None, + ) + + assert same_app.end_user == created.end_user + assert other_app.app_active is True + assert other_app.end_user is None + + +def test_resolve_standard_end_user_retries_generated_session_id_collision( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp(sqlite_session) + generate_session_id = MagicMock(side_effect=["collision", "available"]) + repository = _repository(sqlite_session_factory, generate_session_id=generate_session_id) + app = repository.get_active_web_app("code") + assert app is not None + repository.resolve_standard_end_user(app, "collision") + + resolution = repository.resolve_standard_end_user(app, None) + + assert resolution.end_user is not None + assert generate_session_id.call_count == 2 + persisted = sqlite_session.scalar(select(EndUser).where(EndUser.id == resolution.end_user.id)) + assert persisted is not None + assert persisted.session_id == "available" + + +def test_resolve_standard_end_user_treats_empty_session_as_missing( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp(sqlite_session) + generate_session_id = MagicMock(return_value="generated-session") + repository = _repository(sqlite_session_factory, generate_session_id=generate_session_id) + app = repository.get_active_web_app("code") + assert app is not None + + resolution = repository.resolve_standard_end_user(app, "") + + assert resolution.end_user is not None + generate_session_id.assert_called_once_with() + persisted = sqlite_session.scalar(select(EndUser).where(EndUser.id == resolution.end_user.id)) + assert persisted is not None + assert persisted.session_id == "generated-session" diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 59454af82cd..0a0ebc4ea08 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -5089,9 +5089,9 @@ class TestAgentAppBackingAgent: ) monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_kwargs: "Iris copy") monkeypatch.setattr( - roster_service.FeatureService, - "get_system_features", - lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + roster_service.SystemFeatureService, + "is_webapp_auth_enabled", + lambda: False, ) session.add_all([source_config, target_config, source_app, target_app]) @@ -5177,8 +5177,8 @@ class TestAgentAppBackingAgent: monkeypatch.setattr(roster_service, "AppService", FakeAppService) monkeypatch.setattr( - roster_service.FeatureService, - "get_system_features", + roster_service.SystemFeatureService, + "is_webapp_auth_enabled", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), ) monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) @@ -5241,8 +5241,8 @@ class TestAgentAppBackingAgent: monkeypatch.setattr(roster_service, "AppService", FakeAppService) monkeypatch.setattr( - roster_service.FeatureService, - "get_system_features", + roster_service.SystemFeatureService, + "is_webapp_auth_enabled", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), ) monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) diff --git a/api/tests/unit_tests/services/plugin/conftest.py b/api/tests/unit_tests/services/plugin/conftest.py index 5345db65dc9..ac0d0376898 100644 --- a/api/tests/unit_tests/services/plugin/conftest.py +++ b/api/tests/unit_tests/services/plugin/conftest.py @@ -13,11 +13,11 @@ def make_features( restrict_to_marketplace: bool = False, scope: PluginInstallationScope = PluginInstallationScope.ALL, ) -> MagicMock: - """Create a mock FeatureService.get_system_features() result.""" - features = MagicMock() - features.plugin_installation_permission.restrict_to_marketplace_only = restrict_to_marketplace - features.plugin_installation_permission.plugin_installation_scope = scope - return features + """Create a mock plugin installation permission.""" + permission = MagicMock() + permission.restrict_to_marketplace_only = restrict_to_marketplace + permission.plugin_installation_scope = scope + return permission @pytest.fixture @@ -30,10 +30,10 @@ def mock_installer(monkeypatch: pytest.MonkeyPatch): @pytest.fixture def mock_features(): - """Patch FeatureService to return permissive defaults.""" + """Patch SystemFeatureService to return permissive defaults.""" from unittest.mock import patch features = make_features() - with patch("core.plugin.plugin_service.FeatureService") as mock_fs: - mock_fs.get_system_features.return_value = features + with patch("core.plugin.plugin_service.SystemFeatureService") as mock_fs: + mock_fs.get_plugin_installation_permission.return_value = features yield features diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index 11865f67f7c..cf39f9ecc02 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -1206,7 +1206,7 @@ class TestPluginModelProviderCacheInvalidation: def test_upgrade_plugin_with_marketplace_invalidates_model_provider_cache_for_tenant(self) -> None: """Marketplace upgrades invalidate only the mutated tenant provider cache.""" with ( - patch(f"{MODULE}.FeatureService") as feature_service, + patch(f"{MODULE}.SystemFeatureService") as feature_service, patch(f"{MODULE}.PluginInstaller") as installer_cls, patch(f"{MODULE}.marketplace") as marketplace, patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache, @@ -1314,13 +1314,13 @@ class TestPluginModelProviderCacheInvalidation: def test_install_from_marketplace_pkg_invalidates_model_provider_cache_for_tenant(self) -> None: """Marketplace package installs invalidate only the mutated tenant provider cache.""" with ( - patch(f"{MODULE}.FeatureService") as feature_service, + patch(f"{MODULE}.SystemFeatureService") as feature_service, patch(f"{MODULE}.PluginService._check_plugin_installation_scope"), patch(f"{MODULE}.PluginInstaller") as installer_cls, patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache, ): - feature_service.get_system_features.return_value = SimpleNamespace( - plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False) + feature_service.get_plugin_installation_permission.return_value = SimpleNamespace( + plugin_installation_scope=PluginInstallationScope.ALL, restrict_to_marketplace_only=False ) installer = installer_cls.return_value installer.fetch_plugin_manifest.return_value = MagicMock() diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py index c002b4f2983..2e1d89378c3 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py @@ -123,20 +123,20 @@ class TestFetchLatestPluginVersion: class TestCheckMarketplaceOnlyPermission: - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_raises_when_restricted(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True) with pytest.raises(PluginInstallationForbiddenError): PluginService._check_marketplace_only_permission() - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_passes_when_not_restricted(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False) PluginService._check_marketplace_only_permission() # should not raise - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_raises_when_scope_denies_all(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) @@ -145,7 +145,7 @@ class TestCheckMarketplaceOnlyPermission: class TestCheckPluginInstallationScope: - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_official_only_allows_langgenius(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_ONLY @@ -155,7 +155,7 @@ class TestCheckPluginInstallationScope: PluginService._check_plugin_installation_scope(verification) # should not raise - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_official_only_rejects_third_party(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_ONLY @@ -164,7 +164,7 @@ class TestCheckPluginInstallationScope: with pytest.raises(PluginInstallationForbiddenError): PluginService._check_plugin_installation_scope(None) - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_official_and_partners_allows_partner(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS @@ -174,7 +174,7 @@ class TestCheckPluginInstallationScope: PluginService._check_plugin_installation_scope(verification) # should not raise - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_official_and_partners_rejects_none(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS @@ -183,7 +183,7 @@ class TestCheckPluginInstallationScope: with pytest.raises(PluginInstallationForbiddenError): PluginService._check_plugin_installation_scope(None) - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_none_scope_always_raises(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) verification = MagicMock() @@ -192,13 +192,13 @@ class TestCheckPluginInstallationScope: with pytest.raises(PluginInstallationForbiddenError): PluginService._check_plugin_installation_scope(verification) - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_all_scope_passes_any(self, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL) PluginService._check_plugin_installation_scope(None) # should not raise - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") def test_unknown_scope_always_raises(self, mock_fs): permission = _make_permission() permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope") @@ -262,7 +262,7 @@ class TestUpgradePluginWithMarketplace: PluginService.upgrade_plugin_with_marketplace("t1", "same-uid", "same-uid") @patch("core.plugin.plugin_service.marketplace") - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_skips_download_when_already_installed(self, mock_installer_cls, mock_fs, mock_marketplace): mock_fs.get_plugin_installation_permission.return_value = _make_permission() @@ -276,7 +276,7 @@ class TestUpgradePluginWithMarketplace: installer.upgrade_plugin.assert_called_once() @patch("core.plugin.plugin_service.download_plugin_pkg") - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_downloads_when_not_installed(self, mock_installer_cls, mock_fs, mock_download): mock_fs.get_plugin_installation_permission.return_value = _make_permission() @@ -299,7 +299,7 @@ class TestUpgradePluginWithMarketplace: ) @patch("core.plugin.plugin_service.download_plugin_pkg") @patch("core.plugin.plugin_service.marketplace") - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_rejects_cached_pkg_outside_scope( self, mock_installer_cls, mock_fs, mock_marketplace, mock_download, scope @@ -320,7 +320,7 @@ class TestUpgradePluginWithMarketplace: mock_download.assert_not_called() installer.upload_pkg.assert_not_called() - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_rejects_before_touching_daemon_when_scope_is_none(self, mock_installer_cls, mock_fs): mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) @@ -333,7 +333,7 @@ class TestUpgradePluginWithMarketplace: installer.upgrade_plugin.assert_not_called() @patch("core.plugin.plugin_service.marketplace") - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_allows_cached_official_pkg_under_official_only(self, mock_installer_cls, mock_fs, mock_marketplace): mock_fs.get_plugin_installation_permission.return_value = _make_permission( @@ -352,7 +352,7 @@ class TestUpgradePluginWithMarketplace: class TestUpgradePluginWithGithub: - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_fs.get_plugin_installation_permission.return_value = _make_permission() @@ -367,7 +367,7 @@ class TestUpgradePluginWithGithub: class TestUploadPkg: - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_fs.get_plugin_installation_permission.return_value = _make_permission() @@ -388,7 +388,7 @@ class TestInstallFromMarketplacePkg: PluginService.install_from_marketplace_pkg("t1", ["uid-1"]) @patch("core.plugin.plugin_service.download_plugin_pkg") - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_downloads_when_not_cached(self, mock_installer_cls, mock_fs, mock_download): mock_fs.get_plugin_installation_permission.return_value = _make_permission() @@ -408,7 +408,7 @@ class TestInstallFromMarketplacePkg: call_args = installer.install_from_identifiers.call_args[0] assert call_args[1] == ["resolved-uid"] - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_uses_cached_when_already_downloaded(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_fs.get_plugin_installation_permission.return_value = _make_permission() @@ -426,7 +426,7 @@ class TestInstallFromMarketplacePkg: assert call_args[1] == ["uid-1"] @patch("core.plugin.plugin_service.download_plugin_pkg") - @patch("core.plugin.plugin_service.FeatureService") + @patch("core.plugin.plugin_service.SystemFeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_rejects_cached_pkg_outside_scope(self, mock_installer_cls, mock_fs, mock_download): mock_fs.get_plugin_installation_permission.return_value = _make_permission( diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 52f2bd6764f..268b56753a6 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -143,7 +143,7 @@ class TestAccountService: def mock_external_service_dependencies(self) -> Iterator[_MockDependencies]: """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_feature_service, + patch("services.account_service.SystemFeatureService") as mock_feature_service, patch("services.account_service.BillingService") as mock_billing_service, patch("services.account_service.PassportService") as mock_passport_service, ): @@ -299,7 +299,7 @@ class TestAccountService: ) -> None: """Test successful account creation with all required parameters.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_password_dependencies["hash_password"].return_value = b"hashed_password" @@ -344,7 +344,7 @@ class TestAccountService: sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, ) -> None: - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False sqlite_session.add( @@ -380,7 +380,7 @@ class TestAccountService: mock_external_service_dependencies: _MockDependencies, ) -> None: """Test account creation prefers explicit browser timezone.""" - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_password_dependencies["hash_password"].return_value = b"hashed_password" @@ -408,7 +408,7 @@ class TestAccountService: from controllers.console.error import AccountNotFound # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = False + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = False # Execute test and verify exception with pytest.raises(AccountNotFound): @@ -427,7 +427,7 @@ class TestAccountService: ) -> None: """Test account creation with frozen email address.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with pytest.raises(AccountRegisterError): @@ -441,7 +441,7 @@ class TestAccountService: def test_create_account_suspended_email_domain( self, unbound_session: Session, mock_external_service_dependencies: _MockDependencies ) -> None: - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True mock_external_service_dependencies[ "billing_service" @@ -489,7 +489,7 @@ class TestAccountService: ) -> None: """Test account creation without password (for invite-based registration).""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Execute test @@ -530,7 +530,7 @@ class TestAccountService: sqlite_session_factory: sessionmaker[Session], mock_external_service_dependencies: _MockDependencies, ) -> None: - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False with sqlite_session_factory() as service_session: @@ -826,7 +826,7 @@ class TestTenantService: def mock_external_service_dependencies(self) -> Iterator[_MockDependencies]: """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_feature_service, + patch("services.account_service.SystemFeatureService") as mock_feature_service, patch("services.account_service.BillingService") as mock_billing_service, ): yield { @@ -1737,7 +1737,7 @@ class TestRegisterService: def mock_external_service_dependencies(self) -> Iterator[_MockDependencies]: """Mock setup for external service dependencies.""" with ( - patch("services.account_service.FeatureService") as mock_feature_service, + patch("services.account_service.SystemFeatureService") as mock_feature_service, patch("services.account_service.BillingService") as mock_billing_service, patch("services.account_service.PassportService") as mock_passport_service, ): @@ -1762,7 +1762,7 @@ class TestRegisterService: ) -> None: """Test successful system setup.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account @@ -1813,7 +1813,7 @@ class TestRegisterService: sqlite_session_factory: sessionmaker[Session], mock_external_service_dependencies: _MockDependencies, ) -> None: - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock() @@ -1843,7 +1843,7 @@ class TestRegisterService: sqlite_session_factory: sessionmaker[Session], mock_external_service_dependencies: _MockDependencies, ) -> None: - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies[ "feature_service" ].get_license.return_value.seats.is_available.return_value = True @@ -1879,7 +1879,7 @@ class TestRegisterService: """Enterprise-only side effect should be invoked for the ENTERPRISE edition.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -1923,7 +1923,7 @@ class TestRegisterService: ) -> None: """Enterprise-only side effect should not be invoked for the COMMUNITY edition.""" - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -1958,7 +1958,7 @@ class TestRegisterService: from services.errors.workspace import WorkSpaceNotAllowedCreateError config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -1989,7 +1989,7 @@ class TestRegisterService: ) -> None: """Test successful account registration.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2036,7 +2036,7 @@ class TestRegisterService: sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, ) -> None: - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True sqlite_session.add( Account( name="Existing User", @@ -2064,7 +2064,7 @@ class TestRegisterService: """Enterprise-only side effect should be invoked after successful register commit.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -2096,7 +2096,7 @@ class TestRegisterService: ) -> None: """Enterprise-only side effect should not be invoked for the COMMUNITY edition.""" - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -2130,7 +2130,7 @@ class TestRegisterService: from services.errors.workspace import WorkSpaceNotAllowedCreateError config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2170,7 +2170,7 @@ class TestRegisterService: from services.errors.workspace import WorkspacesLimitExceededError config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2205,7 +2205,7 @@ class TestRegisterService: ) -> None: """Test account registration with OAuth integration.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2258,7 +2258,7 @@ class TestRegisterService: ) -> None: """Test account registration with pending status.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2309,7 +2309,7 @@ class TestRegisterService: ) -> None: """Test registration when workspace creation is not allowed.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ "feature_service" @@ -2343,7 +2343,7 @@ class TestRegisterService: ) -> None: """Test registration with general exception handling.""" # Setup mocks - mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account to raise exception diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index b9bf28be032..a2dadd3a04c 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -132,8 +132,8 @@ class TestCreateAppTransactionBoundary: side_effect=lambda *_args: phase_events.append("external"), ), patch( - "services.app_service.FeatureService.get_system_features", - return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + "services.app_service.SystemFeatureService.is_webapp_auth_enabled", + return_value=False, ), ): app = AppService().create_app( @@ -197,8 +197,8 @@ class TestCreateAppTransactionBoundary: patch("services.app_service.app_was_created.send"), patch("services.app_service.enterprise_rbac_service.try_sync_creator_access_policy_member_bindings"), patch( - "services.app_service.FeatureService.get_system_features", - return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + "services.app_service.SystemFeatureService.is_webapp_auth_enabled", + return_value=False, ), ): app = AppService().create_app( @@ -762,7 +762,7 @@ class TestAgentAppType: patch("services.app_service.app_was_deleted.send"), patch("services.app_service.BillingService"), patch("services.app_service.EnterpriseService"), - patch("services.app_service.FeatureService"), + patch("services.app_service.SystemFeatureService"), patch( "services.app_service.remove_app_and_related_data_task.delay", side_effect=lambda **_kwargs: events.append("enqueue-app-cleanup"), @@ -913,7 +913,7 @@ class TestAgentAppType: with ( patch("services.app_service.current_user", _account_identity(str(uuid4()))), patch("services.app_service.app_was_deleted.send"), - patch("services.app_service.FeatureService"), + patch("services.app_service.SystemFeatureService"), patch("services.app_service.BillingService"), patch("services.app_service.EnterpriseService"), patch("services.app_service.AgentWorkspaceService.retire_all_for_app", return_value=[]), diff --git a/api/tests/unit_tests/services/test_feature_query_service.py b/api/tests/unit_tests/services/test_feature_query_service.py index 9e99acd1c1f..d39e93cb006 100644 --- a/api/tests/unit_tests/services/test_feature_query_service.py +++ b/api/tests/unit_tests/services/test_feature_query_service.py @@ -52,10 +52,24 @@ def test_deployment_queries_delegate_without_request_context() -> None: ) assert service.get_app_dsl_version() == "0.6.0" - assert service.get_system_features() is system_features + assert service.get_public_system_features() is system_features assert service.get_license() is license_model +def test_workspace_id_queries_delegate_without_request_context() -> None: + gateway = create_autospec(FeatureQueryGateway, instance=True, spec_set=True) + features = FeatureModel() + vector_space = VectorSpaceLimitationModel(size=2, limit=10) + gateway.get_workspace_features.return_value = features + gateway.get_vector_space.return_value = vector_space + service = FeatureQueryService(features=gateway, app_dsl_version="0.7.0") + + assert service.get_workspace_features("workspace_123") is features + assert service.get_workspace_vector_space("workspace_123") is vector_space + gateway.get_workspace_features.assert_called_once_with("workspace_123") + gateway.get_vector_space.assert_called_once_with("workspace_123") + + def test_workspace_queries_require_active_workspace() -> None: gateway = create_autospec(FeatureQueryGateway, instance=True, spec_set=True) service = FeatureQueryService(features=gateway, app_dsl_version="0.7.0") diff --git a/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py b/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py deleted file mode 100644 index 9aa5301fbb0..00000000000 --- a/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py +++ /dev/null @@ -1,7 +0,0 @@ -from services.feature_service import FeatureService - - -def test_get_system_features_excludes_app_dsl_version(): - result = FeatureService.get_system_features().model_dump() - - assert "app_dsl_version" not in result diff --git a/api/tests/unit_tests/services/test_feature_service_gateway.py b/api/tests/unit_tests/services/test_feature_service_gateway.py index 8dd981e11f8..c4f8a2efcfd 100644 --- a/api/tests/unit_tests/services/test_feature_service_gateway.py +++ b/api/tests/unit_tests/services/test_feature_service_gateway.py @@ -4,11 +4,16 @@ from enums import DeploymentEdition from services.entities.feature_entities import FeatureModel, SystemFeatureModel from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway +from services.system_feature_service import SystemFeatureService def test_public_system_features_delegate_to_existing_service(mocker: MockerFixture) -> None: system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) - get_system_features = mocker.patch.object(FeatureService, "get_system_features", return_value=system_features) + get_system_features = mocker.patch.object( + SystemFeatureService, + "get_public_system_features", + return_value=system_features, + ) result = FeatureServiceGateway().get_public_system_features() diff --git a/api/tests/unit_tests/services/test_feature_service_internal_policies.py b/api/tests/unit_tests/services/test_feature_service_internal_policies.py deleted file mode 100644 index 8fe271c609c..00000000000 --- a/api/tests/unit_tests/services/test_feature_service_internal_policies.py +++ /dev/null @@ -1,48 +0,0 @@ -from collections.abc import Callable - -import pytest - -from enums import DeploymentEdition -from services.feature_service import FeatureService - - -def test_workspace_creation_uses_environment_policy( - monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] -) -> None: - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, ALLOW_CREATE_WORKSPACE=True) - monkeypatch.setattr( - "services.feature_service.EnterpriseService.get_info", - lambda: (_ for _ in ()).throw(AssertionError("enterprise API should not be called")), - ) - - assert FeatureService.is_workspace_creation_allowed() is True - - -def test_workspace_creation_uses_enterprise_policy( - monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] -) -> None: - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - monkeypatch.setattr( - "services.feature_service.EnterpriseService.get_info", - lambda: {"IsAllowCreateWorkspace": False}, - ) - - assert FeatureService.is_workspace_creation_allowed() is False - - -def test_workspace_creation_keeps_environment_policy_when_enterprise_value_is_missing( - monkeypatch: pytest.MonkeyPatch, - config_overrides: Callable[..., None], -) -> None: - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE, ALLOW_CREATE_WORKSPACE=True) - monkeypatch.setattr("services.feature_service.EnterpriseService.get_info", lambda: {}) - - assert FeatureService.is_workspace_creation_allowed() is True - - -def test_plugin_manager_is_enabled_only_for_enterprise(config_overrides: Callable[..., None]) -> None: - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - assert FeatureService.is_plugin_manager_enabled() is True - - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - assert FeatureService.is_plugin_manager_enabled() is False diff --git a/api/tests/unit_tests/services/test_feature_service_trial_models.py b/api/tests/unit_tests/services/test_feature_service_trial_models.py index c4c70b80ca0..1d2ecdcaa91 100644 --- a/api/tests/unit_tests/services/test_feature_service_trial_models.py +++ b/api/tests/unit_tests/services/test_feature_service_trial_models.py @@ -6,18 +6,19 @@ import pytest from enums import CloudPlan, DeploymentEdition, HostedTrialProvider from services import feature_service as feature_service_module from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService -def test_get_system_features_excludes_trial_models(): - result = FeatureService.get_system_features().model_dump() +def test_get_public_system_features_excludes_trial_models() -> None: + result = SystemFeatureService.get_public_system_features().model_dump() assert "trial_models" not in result def test_get_trial_models_returns_providers_with_paid_or_trial_enabled( config_overrides: Callable[..., None], -): - values: dict[str, bool] = {} +) -> None: + values: dict[str, object] = {} for provider in HostedTrialProvider: values[f"HOSTED_{provider.config_key}_PAID_ENABLED"] = False values[f"HOSTED_{provider.config_key}_TRIAL_ENABLED"] = False diff --git a/api/tests/unit_tests/services/test_inner_mail_service.py b/api/tests/unit_tests/services/test_inner_mail_service.py new file mode 100644 index 00000000000..77d861655a3 --- /dev/null +++ b/api/tests/unit_tests/services/test_inner_mail_service.py @@ -0,0 +1,14 @@ +from unittest.mock import MagicMock + +from services.entities.mail_entities import InnerMailMessage +from services.inner_mail_service import InnerMailService + + +def test_inner_mail_service_delegates_to_dispatcher() -> None: + dispatch = MagicMock() + service = InnerMailService(dispatch=dispatch) + message = InnerMailMessage(recipients=("one@example.com",), subject="Subject", body="Body") + + service.send(message) + + dispatch.assert_called_once_with(message) diff --git a/api/tests/unit_tests/services/test_system_feature_service_app_dsl_version.py b/api/tests/unit_tests/services/test_system_feature_service_app_dsl_version.py new file mode 100644 index 00000000000..0fca8efa601 --- /dev/null +++ b/api/tests/unit_tests/services/test_system_feature_service_app_dsl_version.py @@ -0,0 +1,9 @@ +"""Tests for the public SystemFeatureService app DSL contract.""" + +from services.system_feature_service import SystemFeatureService + + +def test_get_system_features_excludes_app_dsl_version() -> None: + result = SystemFeatureService.get_public_system_features().model_dump() + + assert "app_dsl_version" not in result diff --git a/api/tests/unit_tests/services/test_system_feature_service_change_email.py b/api/tests/unit_tests/services/test_system_feature_service_change_email.py new file mode 100644 index 00000000000..d4cc1666c47 --- /dev/null +++ b/api/tests/unit_tests/services/test_system_feature_service_change_email.py @@ -0,0 +1,37 @@ +from collections.abc import Callable + +import pytest + +from enums import DeploymentEdition +from services.system_feature_service import SystemFeatureService + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_get_system_features_reads_enable_change_email( + config_overrides: Callable[..., None], + enabled: bool, +) -> None: + config_overrides(ENABLE_CHANGE_EMAIL=enabled) + + result = SystemFeatureService.get_public_system_features() + + assert result.enable_change_email is enabled + + +@pytest.mark.parametrize( + ("deployment_edition", "configured", "expected"), + [ + (DeploymentEdition.COMMUNITY, False, False), + (DeploymentEdition.COMMUNITY, True, True), + (DeploymentEdition.ENTERPRISE, True, False), + ], +) +def test_change_email_policy( + config_overrides: Callable[..., None], + deployment_edition: DeploymentEdition, + configured: bool, + expected: bool, +) -> None: + config_overrides(DEPLOYMENT_EDITION=deployment_edition, ENABLE_CHANGE_EMAIL=configured) + + assert SystemFeatureService.is_change_email_enabled() is expected diff --git a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py b/api/tests/unit_tests/services/test_system_feature_service_deployment_edition.py similarity index 81% rename from api/tests/unit_tests/services/test_feature_service_deployment_edition.py rename to api/tests/unit_tests/services/test_system_feature_service_deployment_edition.py index b31e398add4..ddf4ff03538 100644 --- a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py +++ b/api/tests/unit_tests/services/test_system_feature_service_deployment_edition.py @@ -1,3 +1,5 @@ +"""Tests for SystemFeatureService deployment-edition behavior.""" + from collections.abc import Callable from unittest.mock import MagicMock @@ -6,7 +8,7 @@ from pydantic import ValidationError from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService def test_system_feature_model_requires_deployment_edition() -> None: @@ -30,16 +32,16 @@ def test_get_system_features_uses_configured_deployment_edition( fulfill_from_enterprise = MagicMock() config_overrides(DEPLOYMENT_EDITION=edition) monkeypatch.setattr( - "services.feature_service.FeatureService._fulfill_params_from_enterprise", + "services.system_feature_service.SystemFeatureService._fulfill_params_from_enterprise", fulfill_from_enterprise, ) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.deployment_edition is edition assert result.model_dump(mode="json")["deployment_edition"] == edition.value webapp_auth_enabled = edition is DeploymentEdition.ENTERPRISE - assert FeatureService.is_webapp_auth_enabled() is webapp_auth_enabled + assert SystemFeatureService.is_webapp_auth_enabled() is webapp_auth_enabled assert result.webapp_auth.enabled is webapp_auth_enabled if edition is DeploymentEdition.ENTERPRISE: fulfill_from_enterprise.assert_called_once_with(result) @@ -64,4 +66,4 @@ def test_trial_app_policy_is_cloud_only( ) -> None: config_overrides(DEPLOYMENT_EDITION=edition, ENABLE_TRIAL_APP=feature_enabled) - assert FeatureService.is_trial_app_enabled() is expected + assert SystemFeatureService.is_trial_app_enabled() is expected diff --git a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py b/api/tests/unit_tests/services/test_system_feature_service_enable_app_deploy.py similarity index 78% rename from api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py rename to api/tests/unit_tests/services/test_system_feature_service_enable_app_deploy.py index c9aa82a443f..0741f6a6f32 100644 --- a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py +++ b/api/tests/unit_tests/services/test_system_feature_service_enable_app_deploy.py @@ -1,9 +1,11 @@ +"""Tests for the SystemFeatureService app-deployment policy.""" + import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module +from services import system_feature_service as feature_service_module from services.entities.feature_entities import SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService @pytest.mark.parametrize( @@ -21,10 +23,10 @@ from services.feature_service import FeatureService ) def test_fulfill_params_from_enterprise_enable_app_deploy( monkeypatch: pytest.MonkeyPatch, - enterprise_info: dict, + enterprise_info: dict[str, object], initial: bool, expected: bool, -): +) -> None: monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", @@ -34,6 +36,6 @@ def test_fulfill_params_from_enterprise_enable_app_deploy( features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) features.enable_app_deploy = initial - FeatureService._fulfill_params_from_enterprise(features) + SystemFeatureService._fulfill_params_from_enterprise(features) assert features.enable_app_deploy is expected diff --git a/api/tests/unit_tests/services/test_feature_service_explore_banner.py b/api/tests/unit_tests/services/test_system_feature_service_explore_banner.py similarity index 67% rename from api/tests/unit_tests/services/test_feature_service_explore_banner.py rename to api/tests/unit_tests/services/test_system_feature_service_explore_banner.py index ade37592777..7b6fb8ba2c1 100644 --- a/api/tests/unit_tests/services/test_feature_service_explore_banner.py +++ b/api/tests/unit_tests/services/test_system_feature_service_explore_banner.py @@ -1,9 +1,11 @@ +"""Tests for the SystemFeatureService explore-banner policy.""" + from collections.abc import Callable import pytest from enums import DeploymentEdition -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService @pytest.mark.parametrize( @@ -23,9 +25,9 @@ def test_get_system_features_enables_explore_banner_only_for_cloud( expected: bool, ) -> None: config_overrides(DEPLOYMENT_EDITION=edition, ENABLE_EXPLORE_BANNER=configured) - monkeypatch.setattr(FeatureService, "_fulfill_params_from_enterprise", lambda *_: None) + monkeypatch.setattr(SystemFeatureService, "_fulfill_params_from_enterprise", lambda *_: None) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() - assert FeatureService.is_explore_banner_enabled() is expected + assert SystemFeatureService.is_explore_banner_enabled() is expected assert result.enable_explore_banner is expected diff --git a/api/tests/unit_tests/services/test_system_feature_service_internal_policies.py b/api/tests/unit_tests/services/test_system_feature_service_internal_policies.py new file mode 100644 index 00000000000..4b0eebe7f0c --- /dev/null +++ b/api/tests/unit_tests/services/test_system_feature_service_internal_policies.py @@ -0,0 +1,128 @@ +from collections.abc import Callable + +import pytest + +from enums import DeploymentEdition +from services.entities.feature_entities import LicenseStatus +from services.system_feature_service import SystemFeatureService + + +def test_workspace_creation_uses_environment_policy( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, ALLOW_CREATE_WORKSPACE=True) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: (_ for _ in ()).throw(AssertionError("enterprise API should not be called")), + ) + + assert SystemFeatureService.is_workspace_creation_allowed() is True + + +def test_workspace_creation_uses_enterprise_policy( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: {"IsAllowCreateWorkspace": False}, + ) + + assert SystemFeatureService.is_workspace_creation_allowed() is False + + +def test_workspace_creation_keeps_environment_policy_when_enterprise_value_is_missing( + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE, ALLOW_CREATE_WORKSPACE=True) + monkeypatch.setattr("services.system_feature_service.EnterpriseService.get_info", lambda: {}) + + assert SystemFeatureService.is_workspace_creation_allowed() is True + + +def test_plugin_manager_is_enabled_only_for_enterprise(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + assert SystemFeatureService.is_plugin_manager_enabled() is True + + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + assert SystemFeatureService.is_plugin_manager_enabled() is False + + +def test_webapp_auth_enabled_does_not_query_enterprise_info( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: (_ for _ in ()).throw(AssertionError("enterprise info should not be queried")), + ) + + assert SystemFeatureService.is_webapp_auth_enabled() is True + + +def test_registration_policy_uses_enterprise_override( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE, ALLOW_REGISTER=True) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: {"IsAllowRegister": False}, + ) + + assert SystemFeatureService.is_registration_allowed() is False + + +def test_password_login_policy_uses_enterprise_override( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE, ENABLE_EMAIL_PASSWORD_LOGIN=True) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: {"EnableEmailPasswordLogin": False}, + ) + + assert SystemFeatureService.is_email_password_login_enabled() is False + + +def test_branding_reads_enterprise_configuration( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: { + "Branding": { + "applicationTitle": "Enterprise Dify", + "loginPageLogo": "login-logo", + "workspaceLogo": "workspace-logo", + "favicon": "favicon", + } + }, + ) + + branding = SystemFeatureService.get_branding() + + assert branding.enabled is True + assert branding.application_title == "Enterprise Dify" + assert branding.login_page_logo == "login-logo" + assert branding.workspace_logo == "workspace-logo" + assert branding.favicon == "favicon" + + +def test_license_status_ignores_unrelated_malformed_quota_fields( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + monkeypatch.setattr( + "services.system_feature_service.EnterpriseService.get_info", + lambda: { + "License": { + "status": "active", + "workspaces": {"enabled": True, "limit": 3, "used": {"unexpected": "shape"}}, + "licensedSeats": "unexpected-shape", + } + }, + ) + + assert SystemFeatureService.get_license_status() == LicenseStatus.ACTIVE diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py b/api/tests/unit_tests/services/test_system_feature_service_knowledge_fs.py similarity index 87% rename from api/tests/unit_tests/services/test_feature_service_knowledge_fs.py rename to api/tests/unit_tests/services/test_system_feature_service_knowledge_fs.py index 27ea63dc8dc..65073597760 100644 --- a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py +++ b/api/tests/unit_tests/services/test_system_feature_service_knowledge_fs.py @@ -1,10 +1,12 @@ +"""Tests for the SystemFeatureService knowledge-filesystem policy.""" + from collections.abc import Callable import pytest from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService def test_system_feature_model_disables_knowledge_fs_by_default() -> None: @@ -40,7 +42,7 @@ def test_get_system_features_reads_knowledge_fs_availability( KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID="signing-key" if signing_ready else None, KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM=object() if signing_ready else None, ) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.knowledge_fs_enabled is enabled assert result.knowledge_fs_upload_enabled is upload_enabled @@ -68,8 +70,8 @@ def test_get_system_features_controls_knowledge_fs_by_edition_or_development_fla KNOWLEDGE_FS_ENABLED=True, KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED=community_dev_enabled, ) - monkeypatch.setattr(FeatureService, "_fulfill_params_from_enterprise", lambda _: None) + monkeypatch.setattr(SystemFeatureService, "_fulfill_params_from_enterprise", lambda _: None) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.knowledge_fs_enabled is expected diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_system_feature_service_learn_app.py similarity index 69% rename from api/tests/unit_tests/services/test_feature_service_learn_app.py rename to api/tests/unit_tests/services/test_system_feature_service_learn_app.py index dc499aaa414..7bc35d44357 100644 --- a/api/tests/unit_tests/services/test_feature_service_learn_app.py +++ b/api/tests/unit_tests/services/test_system_feature_service_learn_app.py @@ -1,23 +1,25 @@ +"""Tests for SystemFeatureService learn-app and tour policies.""" + from collections.abc import Callable import pytest from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService -def test_system_feature_model_defaults_enable_learn_app(): +def test_system_feature_model_defaults_enable_learn_app() -> None: system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) assert system_features.enable_learn_app is True assert system_features.enable_step_by_step_tour is False @pytest.mark.parametrize("enabled", [True, False]) -def test_get_system_features_reads_enable_learn_app(config_overrides: Callable[..., None], enabled: bool): +def test_get_system_features_reads_enable_learn_app(config_overrides: Callable[..., None], enabled: bool) -> None: config_overrides(ENABLE_LEARN_APP=enabled) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.enable_learn_app is enabled @@ -28,6 +30,6 @@ def test_get_system_features_reads_enable_step_by_step_tour( ) -> None: config_overrides(ENABLE_STEP_BY_STEP_TOUR=enabled) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.enable_step_by_step_tour is enabled diff --git a/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py b/api/tests/unit_tests/services/test_system_feature_service_license_expiry_notice.py similarity index 85% rename from api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py rename to api/tests/unit_tests/services/test_system_feature_service_license_expiry_notice.py index dac43f83706..0ca89ae1933 100644 --- a/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py +++ b/api/tests/unit_tests/services/test_system_feature_service_license_expiry_notice.py @@ -1,11 +1,13 @@ +"""Tests for the SystemFeatureService license-expiry policy.""" + from collections.abc import Callable import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module +from services import system_feature_service as feature_service_module from services.entities.feature_entities import LicenseModel, LicenseStatus -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService _ENTERPRISE_INFO = {"License": {"status": LicenseStatus.EXPIRING, "expiredAt": "2026-12-31"}} @@ -25,7 +27,7 @@ def test_get_license_non_enterprise_ignores_expiry_notice_config( DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, ) - result = FeatureService.get_license() + result = SystemFeatureService.get_license() assert result.license_expiry_notice_enabled is False @@ -45,7 +47,7 @@ def test_get_license_enterprise_reads_license_expiry_notice_enabled( staticmethod(lambda: _ENTERPRISE_INFO), ) - result = FeatureService.get_license() + result = SystemFeatureService.get_license() assert result.status == LicenseStatus.EXPIRING assert result.expired_at == "2026-12-31" diff --git a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py b/api/tests/unit_tests/services/test_system_feature_service_licensed_seats.py similarity index 70% rename from api/tests/unit_tests/services/test_feature_service_licensed_seats.py rename to api/tests/unit_tests/services/test_system_feature_service_licensed_seats.py index 9df759a8f6a..a3f07e2e2ef 100644 --- a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py +++ b/api/tests/unit_tests/services/test_system_feature_service_licensed_seats.py @@ -1,16 +1,20 @@ +"""Tests for SystemFeatureService licensed-seat parsing.""" + from collections.abc import Callable import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module +from services import system_feature_service as feature_service_module from services.entities.feature_entities import LicenseModel, LicenseStatus -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService _ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}} -def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]): +def test_get_license_parses_licensed_seats( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: """The authenticated license accessor copies the licensed-seat quota out of the enterprise payload.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) monkeypatch.setattr( @@ -19,7 +23,7 @@ def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch, conf staticmethod(lambda: _ENTERPRISE_INFO), ) - license_model = FeatureService.get_license() + license_model = SystemFeatureService.get_license() assert isinstance(license_model, LicenseModel) assert license_model.seats.enabled is True @@ -27,11 +31,11 @@ def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch, conf assert license_model.seats.size == 1 -def test_get_license_non_enterprise_is_unconstrained(config_overrides: Callable[..., None]): +def test_get_license_non_enterprise_is_unconstrained(config_overrides: Callable[..., None]) -> None: """Non-enterprise deployments have no license; seat allocation is unconstrained.""" config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - license_model = FeatureService.get_license() + license_model = SystemFeatureService.get_license() assert license_model.status == LicenseStatus.NONE assert license_model.seats.enabled is False diff --git a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py b/api/tests/unit_tests/services/test_system_feature_service_plugin_installation_permission.py similarity index 83% rename from api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py rename to api/tests/unit_tests/services/test_system_feature_service_plugin_installation_permission.py index 4ec58c793ee..cc2490b6983 100644 --- a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py +++ b/api/tests/unit_tests/services/test_system_feature_service_plugin_installation_permission.py @@ -1,12 +1,14 @@ +"""Tests for SystemFeatureService plugin-installation policy.""" + import logging from collections.abc import Callable import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module +from services import system_feature_service as feature_service_module from services.entities.feature_entities import PluginInstallationScope, SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( @@ -14,7 +16,7 @@ def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( ) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - permission = FeatureService.get_plugin_installation_permission() + permission = SystemFeatureService.get_plugin_installation_permission() assert permission.plugin_installation_scope is PluginInstallationScope.ALL assert permission.restrict_to_marketplace_only is False @@ -38,7 +40,7 @@ def test_get_plugin_installation_permission_parses_enterprise_policy( ), ) - permission = FeatureService.get_plugin_installation_permission() + permission = SystemFeatureService.get_plugin_installation_permission() assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY assert permission.restrict_to_marketplace_only is True @@ -62,8 +64,8 @@ def test_invalid_enterprise_policy_denies_all_plugin_installations( caplog: pytest.LogCaptureFixture, invalid_permission: dict[str, object], ) -> None: - with caplog.at_level(logging.ERROR, logger="services.feature_service"): - permission = FeatureService._resolve_plugin_installation_permission( + with caplog.at_level(logging.ERROR, logger="services.system_feature_service"): + permission = SystemFeatureService._resolve_plugin_installation_permission( {"PluginInstallationPermission": invalid_permission} ) @@ -89,7 +91,7 @@ def test_system_features_exposes_only_validated_plugin_installation_policy( ) features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE) - FeatureService._fulfill_params_from_enterprise(features) + SystemFeatureService._fulfill_params_from_enterprise(features) assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE assert features.plugin_installation_permission.restrict_to_marketplace_only is True diff --git a/api/tests/unit_tests/services/test_feature_service_sso_protocol.py b/api/tests/unit_tests/services/test_system_feature_service_sso_protocol.py similarity index 84% rename from api/tests/unit_tests/services/test_feature_service_sso_protocol.py rename to api/tests/unit_tests/services/test_system_feature_service_sso_protocol.py index 0177239077c..b889282e24b 100644 --- a/api/tests/unit_tests/services/test_feature_service_sso_protocol.py +++ b/api/tests/unit_tests/services/test_system_feature_service_sso_protocol.py @@ -1,11 +1,13 @@ +"""Tests for SystemFeatureService SSO protocol parsing.""" + import logging import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module +from services import system_feature_service as feature_service_module from services.entities.feature_entities import SSOProtocol, SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService def test_system_features_exposes_valid_enterprise_sso_protocols( @@ -24,7 +26,7 @@ def test_system_features_exposes_valid_enterprise_sso_protocols( ) features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE) - FeatureService._fulfill_params_from_enterprise(features) + SystemFeatureService._fulfill_params_from_enterprise(features) assert features.sso_enforced_for_signin_protocol is SSOProtocol.SAML assert features.webapp_auth.sso_config.protocol is SSOProtocol.OIDC @@ -48,7 +50,7 @@ def test_system_features_normalizes_empty_enterprise_sso_protocols_to_none( ) features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE) - FeatureService._fulfill_params_from_enterprise(features) + SystemFeatureService._fulfill_params_from_enterprise(features) assert features.sso_enforced_for_signin_protocol is None assert features.webapp_auth.sso_config.protocol is None @@ -73,8 +75,8 @@ def test_system_features_rejects_invalid_enterprise_sso_protocols( ) features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE) - with caplog.at_level(logging.ERROR, logger="services.feature_service"): - FeatureService._fulfill_params_from_enterprise(features) + with caplog.at_level(logging.ERROR, logger="services.system_feature_service"): + SystemFeatureService._fulfill_params_from_enterprise(features) assert features.sso_enforced_for_signin_protocol is None assert features.webapp_auth.sso_config.protocol is None diff --git a/api/tests/unit_tests/services/test_system_feature_service_trial_models.py b/api/tests/unit_tests/services/test_system_feature_service_trial_models.py new file mode 100644 index 00000000000..8760f79da7b --- /dev/null +++ b/api/tests/unit_tests/services/test_system_feature_service_trial_models.py @@ -0,0 +1,9 @@ +"""Tests for the public SystemFeatureService hosted-model contract.""" + +from services.system_feature_service import SystemFeatureService + + +def test_get_system_features_excludes_trial_models() -> None: + result = SystemFeatureService.get_public_system_features().model_dump() + + assert "trial_models" not in result diff --git a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py b/api/tests/unit_tests/services/test_system_feature_service_webapp_public_access.py similarity index 73% rename from api/tests/unit_tests/services/test_feature_service_webapp_public_access.py rename to api/tests/unit_tests/services/test_system_feature_service_webapp_public_access.py index ba6bc45c357..69757ebd7cd 100644 --- a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py +++ b/api/tests/unit_tests/services/test_system_feature_service_webapp_public_access.py @@ -1,10 +1,12 @@ +"""Tests for the SystemFeatureService WebApp public-access policy.""" + from collections.abc import Callable import pytest from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel -from services.feature_service import FeatureService +from services.system_feature_service import SystemFeatureService @pytest.mark.parametrize( @@ -19,16 +21,16 @@ def test_fulfill_system_params_from_env_sets_allow_public_access( config_overrides: Callable[..., None], env_value: bool, expected: bool, -): +) -> None: config_overrides(WEBAPP_PUBLIC_ACCESS_ENABLED=env_value) system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) - FeatureService._fulfill_system_params_from_env(system_features) + SystemFeatureService._fulfill_system_params_from_env(system_features) assert system_features.webapp_auth.allow_public_access is expected -def test_get_system_features_defaults_allow_public_access_to_true(): - system_features = FeatureService.get_system_features() +def test_get_system_features_defaults_allow_public_access_to_true() -> None: + system_features = SystemFeatureService.get_public_system_features() assert system_features.webapp_auth.allow_public_access is True diff --git a/api/tests/unit_tests/services/test_web_passport_gateways.py b/api/tests/unit_tests/services/test_web_passport_gateways.py new file mode 100644 index 00000000000..48806681a18 --- /dev/null +++ b/api/tests/unit_tests/services/test_web_passport_gateways.py @@ -0,0 +1,72 @@ +"""Unit tests for the outer gateways used by web passport issuance.""" + +from unittest.mock import MagicMock + +import pytest +from werkzeug.exceptions import Unauthorized + +from services.enterprise.enterprise_service import WebAppAccessMode, WebAppSettings +from services.web_passport_gateways import DeploymentWebPassportAuthGateway, PassportTokenGateway +from services.web_passport_service import WebAppAuthType, WebPassportUnauthorizedError + + +def test_deployment_auth_gateway_reads_deployment_setting() -> None: + gateway = DeploymentWebPassportAuthGateway( + webapp_auth_enabled=True, + get_app_access_mode=MagicMock(), + ) + + assert gateway.is_webapp_auth_enabled() is True + + +@pytest.mark.parametrize( + ("access_mode", "expected"), + [ + (WebAppAccessMode.PUBLIC, WebAppAuthType.PUBLIC), + (WebAppAccessMode.PRIVATE, WebAppAuthType.INTERNAL), + (WebAppAccessMode.PRIVATE_ALL, WebAppAuthType.INTERNAL), + (WebAppAccessMode.SSO_VERIFIED, WebAppAuthType.EXTERNAL), + ], +) +def test_deployment_auth_gateway_delegates_access_mode_mapping( + access_mode: WebAppAccessMode, + expected: WebAppAuthType, +) -> None: + get_access_mode = MagicMock(return_value=WebAppSettings(accessMode=access_mode)) + gateway = DeploymentWebPassportAuthGateway( + webapp_auth_enabled=True, + get_app_access_mode=get_access_mode, + ) + + assert gateway.get_app_auth_type("app-1") == expected + get_access_mode.assert_called_once_with("app-1") + + +def test_passport_token_gateway_delegates_issue_and_verify() -> None: + passport = MagicMock() + passport.verify.return_value = {"sub": "account-1"} + passport.issue.return_value = "issued-token" + gateway = PassportTokenGateway(passport=passport) + + assert gateway.verify("input-token") == {"sub": "account-1"} + assert gateway.issue({"sub": "account-1"}) == "issued-token" + passport.verify.assert_called_once_with("input-token") + passport.issue.assert_called_once_with({"sub": "account-1"}) + + +def test_passport_token_gateway_translates_unauthorized() -> None: + passport = MagicMock() + passport.verify.side_effect = Unauthorized("Token has expired.") + gateway = PassportTokenGateway(passport=passport) + + with pytest.raises(WebPassportUnauthorizedError, match="Token has expired"): + gateway.verify("expired-token") + + +def test_passport_token_gateway_defaults_empty_unauthorized_description() -> None: + passport = MagicMock() + passport.verify.side_effect = Unauthorized("") + gateway = PassportTokenGateway(passport=passport) + + with pytest.raises(WebPassportUnauthorizedError, match="Invalid token"): + gateway.verify("invalid-token") diff --git a/api/tests/unit_tests/services/test_web_passport_service.py b/api/tests/unit_tests/services/test_web_passport_service.py new file mode 100644 index 00000000000..446d4ead07d --- /dev/null +++ b/api/tests/unit_tests/services/test_web_passport_service.py @@ -0,0 +1,243 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest + +from services.entities.passport_entities import ( + EndUserRecord, + WebAppRecord, + WebPassportEndUserResolution, + WebPassportRequest, +) +from services.web_passport_service import ( + WebAppAuthType, + WebPassportAuthenticationRequiredError, + WebPassportNotFoundError, + WebPassportService, + WebPassportUnauthorizedError, +) + +APP = WebAppRecord(site_id="site-1", app_id="app-1", tenant_id="tenant-1", app_code="app-code") +NOW = datetime(2026, 8, 13, 12, 0, tzinfo=UTC) + + +def _service( + *, + repository: MagicMock | None = None, + auth: MagicMock | None = None, + tokens: MagicMock | None = None, +) -> tuple[WebPassportService, MagicMock, MagicMock, MagicMock]: + if repository is None: + repository = MagicMock() + repository.get_active_web_app.return_value = APP + repository.is_web_app_active.return_value = True + resolution = WebPassportEndUserResolution(app_active=True, end_user=EndUserRecord(id="end-user-1")) + repository.resolve_standard_end_user.return_value = resolution + repository.resolve_authenticated_end_user.return_value = resolution + if auth is None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = False + if tokens is None: + tokens = MagicMock() + tokens.issue.return_value = "issued-token" + now = MagicMock(return_value=NOW) + service = WebPassportService( + passports=repository, + auth=auth, + tokens=tokens, + now=now, + access_token_expire_minutes=60, + ) + return service, repository, auth, tokens + + +def _request(*, user_session_id: str | None = None, access_token: str | None = None) -> WebPassportRequest: + return WebPassportRequest(app_code="app-code", user_session_id=user_session_id, access_token=access_token) + + +def test_issue_creates_anonymous_user_and_standard_passport() -> None: + service, repository, _auth, tokens = _service() + + result = service.issue(_request()) + + assert result.access_token == "issued-token" + repository.resolve_standard_end_user.assert_called_once_with(APP, None) + tokens.issue.assert_called_once_with( + { + "iss": "app-1", + "sub": "Web API Passport", + "app_id": "app-1", + "app_code": "app-code", + "end_user_id": "end-user-1", + } + ) + + +def test_issue_reuses_requested_session_user() -> None: + service, repository, _auth, _tokens = _service() + + service.issue(_request(user_session_id="existing-session")) + + repository.resolve_standard_end_user.assert_called_once_with(APP, "existing-session") + + +def test_issue_returns_not_found_for_inactive_app() -> None: + repository = MagicMock() + repository.get_active_web_app.return_value = None + service, _repository, auth, _tokens = _service(repository=repository) + + with pytest.raises(WebPassportNotFoundError): + service.issue(_request()) + + auth.is_webapp_auth_enabled.assert_not_called() + + +def test_issue_revalidates_app_after_enterprise_io() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL + tokens = MagicMock() + tokens.verify.return_value = { + "token_source": "webapp_login_token", + "auth_type": "internal", + "session_id": "session-1", + } + service, repository, _auth, _tokens = _service(auth=auth, tokens=tokens) + + def app_is_active(_app: WebAppRecord) -> bool: + auth.get_app_auth_type.assert_called_once_with(APP.app_id) + return False + + repository.is_web_app_active.side_effect = app_is_active + + with pytest.raises(WebPassportNotFoundError): + service.issue(_request(access_token="login-token")) + + repository.resolve_authenticated_end_user.assert_not_called() + tokens.issue.assert_not_called() + + +def test_issue_returns_not_found_when_app_becomes_inactive_before_user_creation() -> None: + service, repository, _auth, tokens = _service() + repository.resolve_standard_end_user.return_value = WebPassportEndUserResolution( + app_active=False, + end_user=None, + ) + + with pytest.raises(WebPassportNotFoundError): + service.issue(_request()) + + tokens.issue.assert_not_called() + + +def test_issue_requires_login_for_private_webapp() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL + service, _repository, _auth, _tokens = _service(auth=auth) + + with pytest.raises(WebPassportAuthenticationRequiredError): + service.issue(_request()) + + +def test_issue_rejects_wrong_login_token_source() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL + tokens = MagicMock() + tokens.verify.return_value = {"token_source": "other"} + service, _repository, _auth, _tokens = _service(auth=auth, tokens=tokens) + + with pytest.raises(WebPassportUnauthorizedError, match="token source"): + service.issue(_request(access_token="login-token")) + + +def test_issue_rejects_auth_type_mismatch() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.EXTERNAL + tokens = MagicMock() + tokens.verify.return_value = { + "token_source": "webapp_login_token", + "auth_type": "internal", + "session_id": "session-1", + } + service, _repository, _auth, _tokens = _service(auth=auth, tokens=tokens) + + with pytest.raises(WebPassportAuthenticationRequiredError, match="external"): + service.issue(_request(access_token="login-token")) + + +def test_issue_exchanges_enterprise_token_after_user_resolution() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL + tokens = MagicMock() + tokens.verify.return_value = { + "token_source": "webapp_login_token", + "user_id": "account-1", + "end_user_id": "stale-end-user", + "session_id": "session-1", + "auth_type": "internal", + "exp": 2_000_000_000, + } + tokens.issue.return_value = "enterprise-token" + service, repository, _auth, _tokens = _service(auth=auth, tokens=tokens) + repository.resolve_authenticated_end_user.return_value = WebPassportEndUserResolution( + app_active=True, + end_user=EndUserRecord(id="end-user-by-session"), + ) + + result = service.issue(_request(access_token="login-token")) + + assert result.access_token == "enterprise-token" + repository.resolve_authenticated_end_user.assert_called_once_with( + APP, + end_user_id="stale-end-user", + session_id="session-1", + ) + tokens.issue.assert_called_once_with( + { + "iss": "site-1", + "sub": "Web API Passport", + "app_id": "app-1", + "app_code": "app-code", + "user_id": "account-1", + "end_user_id": "end-user-by-session", + "auth_type": "internal", + "granted_at": int(NOW.timestamp()), + "token_source": "webapp", + "exp": 2_000_000_000, + } + ) + + +def test_issue_requires_session_id_when_enterprise_user_is_missing() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL + tokens = MagicMock() + tokens.verify.return_value = {"token_source": "webapp_login_token", "auth_type": "internal"} + service, repository, _auth, _tokens = _service(auth=auth, tokens=tokens) + repository.resolve_authenticated_end_user.return_value = WebPassportEndUserResolution( + app_active=True, + end_user=None, + ) + + with pytest.raises(WebPassportNotFoundError, match="Missing session_id"): + service.issue(_request(access_token="login-token")) + + +def test_public_webapp_verifies_optional_login_token_then_uses_standard_flow() -> None: + auth = MagicMock() + auth.is_webapp_auth_enabled.return_value = True + auth.get_app_auth_type.return_value = WebAppAuthType.PUBLIC + tokens = MagicMock() + tokens.verify.return_value = {"token_source": "webapp_login_token"} + tokens.issue.return_value = "public-token" + service, _repository, _auth, _tokens = _service(auth=auth, tokens=tokens) + + service.issue(_request(access_token="login-token")) + + tokens.verify.assert_called_once_with("login-token") + assert tokens.issue.call_args.args[0]["iss"] == "app-1" diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 5d4e3d92ced..da1ea117964 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -1469,8 +1469,8 @@ class TestWorkflowService: with ( patch( - "services.feature_service.FeatureService.get_system_features", - return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=False)), + "services.system_feature_service.SystemFeatureService.is_plugin_manager_enabled", + return_value=False, ), pytest.raises(ValueError, match=error), ): diff --git a/api/uv.lock b/api/uv.lock index 9a89afe4c82..d7539edbf7a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1763,7 +1763,7 @@ storage = [ ] tools = [ { name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" }, - { name = "nltk", specifier = ">=3.10.0,<4.0.0" }, + { name = "nltk", specifier = ">=3.10.3,<4.0.0" }, ] trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }] trace-all = [ @@ -4134,7 +4134,7 @@ wheels = [ [[package]] name = "nltk" -version = "3.10.0" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -4143,9 +4143,9 @@ dependencies = [ { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" }, ] [[package]] @@ -5423,11 +5423,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.15.0" +version = "6.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/5a/df92d1c1ef8806ca28f20f978ee059894868d93de797a7e2edebe7fe1a43/pypdf-6.16.1.tar.gz", hash = "sha256:c4d1b43ddae921387321cf63936cd16a7743b91d2da92f165c149a195c972ba9", size = 7003737, upload-time = "2026-08-14T12:24:04.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/33/a1/724b18d6757ab7253a8fecd3a430eb8d980ed26872ba16651e7b5ddfc63f/pypdf-6.16.1-py3-none-any.whl", hash = "sha256:63fec31c4092ae50b6729beedcb469055b60d20c834bde1c402df241f371f644", size = 382924, upload-time = "2026-08-14T12:24:02.854Z" }, ] [[package]] diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 2b579f401ff..322325d7c5d 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — difyctl (TypeScript CLI) -This package is the Node 22+, ESM TypeScript implementation of `difyctl`. Development also requires the Bun version pinned in `.bun-version`; command-tree generation and the `dev`, `test`, and `build` pre-scripts invoke it. Read [`ARD.md`] before adding a command or changing shared CLI infrastructure. Read `src/commands/AGENTS.md` for command-folder and registry rules. +This package is the Node 24+, ESM TypeScript implementation of `difyctl`. Development also requires the Bun version pinned in `.bun-version`; command-tree generation and the `dev`, `test`, and `build` pre-scripts invoke it. Read [`ARD.md`] before adding a command or changing shared CLI infrastructure. Read `src/commands/AGENTS.md` for command-folder and registry rules. ## Architecture Boundaries diff --git a/cli/package.json b/cli/package.json index 1c49f13137a..a2b750adb5f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -66,7 +66,7 @@ "vitest": "catalog:" }, "engines": { - "node": "^22.22.1" + "node": "^24.20.0" }, "difyctl": { "channel": "stable", diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 85b0e264cbe..451c0312d63 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ sourcemap: true, treeshake: false, outDir: 'dist', - target: 'node22', + target: 'node24', define: { __DIFYCTL_VERSION__: JSON.stringify(buildInfo.version), __DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit), diff --git a/cli/vitest.e2e.config.ts b/cli/vitest.e2e.config.ts index 5ea77e18c1e..bc56c232f9c 100644 --- a/cli/vitest.e2e.config.ts +++ b/cli/vitest.e2e.config.ts @@ -43,7 +43,7 @@ export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], outDir: 'dist', - target: 'node22', + target: 'node24', define: { __DIFYCTL_VERSION__: JSON.stringify(buildInfo.version), __DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit), diff --git a/dify-agent-runtime/docker/Dockerfile b/dify-agent-runtime/docker/Dockerfile index f8d38413e94..a8a25659e02 100644 --- a/dify-agent-runtime/docker/Dockerfile +++ b/dify-agent-runtime/docker/Dockerfile @@ -20,7 +20,7 @@ RUN CGO_ENABLED=0 go build -o /bin/shellctl ./cmd/shellctl && \ # ── Runtime stage ──────────────────────────────────────────────────────────── FROM python:3.12-slim-bookworm AS production -ARG NODE_VERSION=22.22.1 +ARG NODE_VERSION=24.20.0 ARG PNPM_VERSION=11.9.0 ARG UV_VERSION=0.8.9 diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 50294c9bf85..24d3390f7ec 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -11,8 +11,17 @@ DIFY_AGENT_REDIS_PREFIX=dify-agent # Shutdown and retention # Seconds to wait for active local runs during graceful shutdown before cancellation. DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30 -# Seconds to retain Redis run records and per-run event streams (default: 3 days). -DIFY_AGENT_RUN_RETENTION_SECONDS=259200 +# Seconds to retain Redis run records and per-run event streams after the last write (default: 2 hours). +DIFY_AGENT_RUN_RETENTION_SECONDS=7200 +# Approximate target maximum for replayable events retained in each per-run Redis Stream. +DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH=5000 +# Set false to publish every text delta without coalescing. +DIFY_AGENT_STREAM_TEXT_DELTA_COALESCING_ENABLED=true +# Soft debounce interval for compatible text deltas. Already-ready events may continue +# to merge past this interval; a waiting source triggers the timed flush. +DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS=100 +# Flush a text-delta batch immediately once it reaches this many characters. +DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS=4096 # Plugin daemon # Base URL for the Dify plugin daemon used by local runs. diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index de72c0ba286..99ff7b0ac28 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -34,7 +34,11 @@ also reads `.env` and `dify-agent/.env` when present. | `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. | | `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Prefix for Redis record and event keys. | | `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` | `30` | Seconds to wait for active local runs during graceful shutdown before cancellation. | -| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. | +| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `7200` | Seconds to retain Redis run records and per-run event streams after their last write; defaults to 2 hours. | +| `DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH` | `5000` | Approximate target maximum for replayable events retained in each per-run Redis Stream. | +| `DIFY_AGENT_STREAM_TEXT_DELTA_COALESCING_ENABLED` | `true` | Set `false` to publish each text delta without coalescing. | +| `DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS` | `100` | Soft debounce interval for compatible text deltas. Already-ready source events may continue to merge after this interval; a waiting source triggers the timed flush. Must be greater than zero; use `DIFY_AGENT_STREAM_TEXT_DELTA_COALESCING_ENABLED=false` to disable coalescing. | +| `DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS` | `4096` | Character threshold that flushes a buffered text-delta event immediately. | | `DIFY_AGENT_RUN_TIMEOUT_SECONDS` | `3600` | Wall-clock deadline in seconds for the Pydantic AI `agent.run(...)` model/tool loop. Deadline failures use `agent_run_limit_exceeded`. Its default intentionally matches `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS`, but the settings are independently configurable. | | `DIFY_AGENT_BINDING_FILE_DOWNLOAD_COMMAND_TIMEOUT_SECONDS` | `210` | Shell command deadline for running the sandbox `dify-agent file upload --no-download-link` conversion. Keep it above the CLI's 180-second upload deadline. | | `DIFY_AGENT_API_TOKEN` | empty | Optional Bearer token required by private run, Execution Binding, Home Snapshot, and Binding file control-plane routes. Must match Dify API `AGENT_BACKEND_API_TOKEN`. | @@ -75,7 +79,10 @@ Example `.env`: DIFY_AGENT_REDIS_URL=redis://localhost:6379/0 DIFY_AGENT_REDIS_PREFIX=dify-agent-dev DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30 -DIFY_AGENT_RUN_RETENTION_SECONDS=259200 +DIFY_AGENT_RUN_RETENTION_SECONDS=7200 +DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH=5000 +DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS=100 +DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS=4096 DIFY_AGENT_RUN_TIMEOUT_SECONDS=3600 DIFY_AGENT_API_TOKEN=replace-with-agent-backend-token DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 @@ -138,7 +145,15 @@ and lifecycle contract. Run records and event streams use the same retention. Status writes refresh the record TTL, and event writes refresh both the stream TTL and the corresponding -record TTL so active runs that keep producing events remain observable. +record TTL so active runs that keep producing events remain observable. Text +deltas for the same response part and provider metadata are coalesced within a +soft debounce/size window before being published. The debounce timer flushes +when reading the next source event would block; already-ready events may keep +merging until the character threshold or a non-compatible event is reached. +Every Redis Stream write also applies the configured approximate maximum length. A reconnect cursor older than the +retained window resumes from the oldest remaining event, so callers must treat +the terminal snapshot and application-owned history as authoritative rather +than relying on the Agent event stream as long-term history. ## Validate the E2B Compose deployment diff --git a/dify-agent/src/dify_agent/runtime/event_coalescer.py b/dify-agent/src/dify_agent/runtime/event_coalescer.py new file mode 100644 index 00000000000..a35d2c52fe3 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/event_coalescer.py @@ -0,0 +1,143 @@ +"""Bound high-frequency text deltas before they reach the shared event sink.""" + +import asyncio +from collections.abc import AsyncIterable, AsyncIterator +from contextlib import suppress +from dataclasses import replace + +from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, TextPartDelta + + +DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS = 0.1 +DEFAULT_TEXT_DELTA_MAX_CHARS = 4096 + + +async def coalesce_agent_stream_events( + events: AsyncIterable[AgentStreamEvent], + *, + enabled: bool = True, + flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, + max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS, +) -> AsyncIterator[AgentStreamEvent]: + """Merge compatible text deltas with a soft debounce interval. + + One source event is read ahead while a text delta is buffered. Non-text events + flush the buffer first, preserving the public Pydantic AI event ordering. The + timer starts with the first buffered delta instead of sliding on every token. + Already-ready source events may continue to merge after the interval; the + interval triggers a flush once reading the next source event would block. + """ + if flush_interval_seconds <= 0: + raise ValueError("flush_interval_seconds must be positive") + if max_chars <= 0: + raise ValueError("max_chars must be positive") + if not enabled: + async for event in events: + yield event + return + + iterator = aiter(events) + next_event_task: asyncio.Future[AgentStreamEvent] | None = asyncio.ensure_future(anext(iterator)) + buffered: PartDeltaEvent | None = None + flush_deadline: float | None = None + loop = asyncio.get_running_loop() + + try: + while next_event_task is not None: + timeout = None if flush_deadline is None else max(0.0, flush_deadline - loop.time()) + try: + done, _pending = await asyncio.wait((next_event_task,), timeout=timeout) + except asyncio.CancelledError: + if buffered is not None: + yield buffered + raise + + if not done: + assert buffered is not None + yield buffered + buffered = None + flush_deadline = None + continue + + try: + event = next_event_task.result() + except StopAsyncIteration: + next_event_task = None + if buffered is not None: + yield buffered + return + except BaseException: + next_event_task = None + if buffered is not None: + yield buffered + raise + + next_event_task = asyncio.ensure_future(anext(iterator)) + text_delta = _text_delta(event) + if text_delta is None: + if buffered is not None: + yield buffered + buffered = None + flush_deadline = None + yield event + continue + + if buffered is not None and _can_merge(buffered, event): + buffered = _merge_text_deltas(buffered, event) + else: + if buffered is not None: + yield buffered + assert isinstance(event, PartDeltaEvent) + buffered = event + flush_deadline = loop.time() + flush_interval_seconds + + buffered_delta = _text_delta(buffered) + assert buffered_delta is not None + if len(buffered_delta.content_delta) >= max_chars: + yield buffered + buffered = None + flush_deadline = None + finally: + if next_event_task is not None and not next_event_task.done(): + next_event_task.cancel() + with suppress(asyncio.CancelledError, StopAsyncIteration): + _ = await next_event_task + + +def _text_delta(event: AgentStreamEvent) -> TextPartDelta | None: + if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta): + return event.delta + return None + + +def _can_merge(left: PartDeltaEvent, right: AgentStreamEvent) -> bool: + right_delta = _text_delta(right) + if right_delta is None: + return False + left_delta = left.delta + assert isinstance(left_delta, TextPartDelta) + assert isinstance(right, PartDeltaEvent) + return ( + left.index == right.index + and left_delta.provider_name == right_delta.provider_name + and left_delta.provider_details == right_delta.provider_details + ) + + +def _merge_text_deltas(left: PartDeltaEvent, right: AgentStreamEvent) -> PartDeltaEvent: + left_delta = left.delta + right_delta = _text_delta(right) + assert isinstance(left_delta, TextPartDelta) + assert right_delta is not None + merged_delta = replace( + left_delta, + content_delta=left_delta.content_delta + right_delta.content_delta, + ) + return replace(left, delta=merged_delta) + + +__all__ = [ + "DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS", + "DEFAULT_TEXT_DELTA_MAX_CHARS", + "coalesce_agent_stream_events", +] diff --git a/dify-agent/src/dify_agent/runtime/run_scheduler.py b/dify-agent/src/dify_agent/runtime/run_scheduler.py index a0bf260bd25..5a57999547f 100644 --- a/dify-agent/src/dify_agent/runtime/run_scheduler.py +++ b/dify-agent/src/dify_agent/runtime/run_scheduler.py @@ -24,6 +24,10 @@ from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput from dify_agent.protocol.schemas import AgentRunUsage, CancelRunRequest, CancelRunResponse, CreateRunRequest, RunStatus from dify_agent.runtime.cancellation import RunCancellationIntent from dify_agent.runtime.compositor_factory import create_default_layer_providers +from dify_agent.runtime.event_coalescer import ( + DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, + DEFAULT_TEXT_DELTA_MAX_CHARS, +) from dify_agent.runtime.event_sink import RunEventSink, RunFinalizationResult, emit_run_failed from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, AgentRunRunner from dify_agent.server.schemas import RunRecord @@ -106,6 +110,9 @@ class RunScheduler: store: RunStore shutdown_grace_seconds: float run_timeout_seconds: float + stream_text_delta_coalescing_enabled: bool + stream_text_delta_flush_interval_seconds: float + stream_text_delta_max_chars: int active_tasks: dict[str, asyncio.Task[None]] stopping: bool runner_factory: RunRunnerFactory | None @@ -122,12 +129,18 @@ class RunScheduler: dify_api_http_client: httpx.AsyncClient, shutdown_grace_seconds: float = 30, run_timeout_seconds: float = DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, + stream_text_delta_coalescing_enabled: bool = True, + stream_text_delta_flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, + stream_text_delta_max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS, layer_providers: tuple[LayerProviderInput, ...] | None = None, runner_factory: RunRunnerFactory | None = None, ) -> None: self.store = store self.shutdown_grace_seconds = shutdown_grace_seconds self.run_timeout_seconds = run_timeout_seconds + self.stream_text_delta_coalescing_enabled = stream_text_delta_coalescing_enabled + self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds + self.stream_text_delta_max_chars = stream_text_delta_max_chars self.active_tasks = {} self.stopping = False self.plugin_daemon_http_client = plugin_daemon_http_client @@ -304,6 +317,9 @@ class RunScheduler: layer_providers=self.layer_providers, is_cancelled=is_cancelled, run_timeout_seconds=self.run_timeout_seconds, + stream_text_delta_coalescing_enabled=self.stream_text_delta_coalescing_enabled, + stream_text_delta_flush_interval_seconds=self.stream_text_delta_flush_interval_seconds, + stream_text_delta_max_chars=self.stream_text_delta_max_chars, ) def _discard_active_run(self, run_id: str) -> None: diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index b7dcd127df1..b20edcbb74f 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -7,8 +7,9 @@ policy is validated: - model runs: enter a fresh ``CompositorRun`` (or resume one from a snapshot), pass the current Dify system prompts as run-level instructions, run pydantic-ai with either the current ``run.user_prompts`` or deferred external - tool results, emit raw stream events with agent-message delta annotations, apply - request-level ``on_exit`` signals, and publish a terminal success or failure event; + tool results, emit stream events with bounded text-delta coalescing and + agent-message annotations, apply request-level ``on_exit`` signals, and publish + a terminal success or failure event; The Pydantic AI model is resolved from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored message history only through session state. Once pydantic-ai binds and builds @@ -67,6 +68,11 @@ from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_ru from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers from dify_agent.runtime.compaction import build_compaction_capability from dify_agent.runtime_backend import BindingLostError +from dify_agent.runtime.event_coalescer import ( + DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, + DEFAULT_TEXT_DELTA_MAX_CHARS, + coalesce_agent_stream_events, +) from dify_agent.runtime.event_sink import ( RunEventSink, emit_pydantic_ai_event, @@ -187,6 +193,9 @@ class AgentRunRunner: dify_api_http_client: httpx.AsyncClient is_cancelled: Callable[[], bool] run_timeout_seconds: float + stream_text_delta_coalescing_enabled: bool + stream_text_delta_flush_interval_seconds: float + stream_text_delta_max_chars: int _terminal_session_snapshot: CompositorSessionSnapshot | None _terminal_usage: AgentRunUsage | None @@ -201,7 +210,14 @@ class AgentRunRunner: layer_providers: tuple[LayerProviderInput, ...] | None = None, is_cancelled: Callable[[], bool] | None = None, run_timeout_seconds: float = DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, + stream_text_delta_coalescing_enabled: bool = True, + stream_text_delta_flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, + stream_text_delta_max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS, ) -> None: + if stream_text_delta_flush_interval_seconds <= 0: + raise ValueError("stream_text_delta_flush_interval_seconds must be positive") + if stream_text_delta_max_chars <= 0: + raise ValueError("stream_text_delta_max_chars must be positive") self.sink = sink self.request = request self.run_id = run_id @@ -210,6 +226,9 @@ class AgentRunRunner: self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() self.is_cancelled = is_cancelled or (lambda: False) self.run_timeout_seconds = run_timeout_seconds + self.stream_text_delta_coalescing_enabled = stream_text_delta_coalescing_enabled + self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds + self.stream_text_delta_max_chars = stream_text_delta_max_chars self._terminal_session_snapshot = None self._terminal_usage = None @@ -320,7 +339,13 @@ class AgentRunRunner: raise AgentRunValidationError(EMPTY_USER_PROMPTS_ERROR) async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None: - async for event in events: + published_events = coalesce_agent_stream_events( + events, + enabled=self.stream_text_delta_coalescing_enabled, + flush_interval_seconds=self.stream_text_delta_flush_interval_seconds, + max_chars=self.stream_text_delta_max_chars, + ) + async for event in published_events: if self.is_cancelled(): raise asyncio.CancelledError text_delta = _extract_agent_message_delta(event) diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 8144c66ae20..4b1d4ce3782 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -105,6 +105,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: redis, prefix=resolved_settings.redis_prefix, run_retention_seconds=resolved_settings.run_retention_seconds, + run_event_stream_max_length=resolved_settings.run_event_stream_max_length, ) scheduler = RunScheduler( store=store, @@ -112,6 +113,9 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: dify_api_http_client=dify_api_inner_http_client, shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds, run_timeout_seconds=resolved_settings.run_timeout_seconds, + stream_text_delta_coalescing_enabled=resolved_settings.stream_text_delta_coalescing_enabled, + stream_text_delta_flush_interval_seconds=(resolved_settings.stream_text_delta_flush_interval_ms / 1000), + stream_text_delta_max_chars=resolved_settings.stream_text_delta_max_chars, layer_providers=layer_providers, ) state["store"] = store diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 3d27e4439b7..80669d6bf19 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -21,6 +21,10 @@ from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_b from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key +from dify_agent.runtime.event_coalescer import ( + DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, + DEFAULT_TEXT_DELTA_MAX_CHARS, +) from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS from dify_agent.runtime_backend import RuntimeBackendProfile from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS @@ -32,7 +36,8 @@ from dify_agent.runtime_backend.profile import ( create_runtime_backend_profile, ) -DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60 +DEFAULT_RUN_RETENTION_SECONDS = 2 * 60 * 60 +DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH = 5000 class ServerSettings(BaseSettings): @@ -42,6 +47,13 @@ class ServerSettings(BaseSettings): redis_prefix: str = "dify-agent" shutdown_grace_seconds: float = 30 run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1) + run_event_stream_max_length: int = Field(default=DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, ge=1) + stream_text_delta_coalescing_enabled: bool = True + stream_text_delta_flush_interval_ms: int = Field( + default=int(DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS * 1000), + ge=1, + ) + stream_text_delta_max_chars: int = Field(default=DEFAULT_TEXT_DELTA_MAX_CHARS, ge=1) run_timeout_seconds: float = Field(default=DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, gt=0) plugin_daemon_url: str = "http://localhost:5002" plugin_daemon_api_key: str = "" @@ -255,4 +267,4 @@ class ServerSettings(BaseSettings): ) -__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"] +__all__ = ["DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH", "DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"] diff --git a/dify-agent/src/dify_agent/storage/redis_run_store.py b/dify-agent/src/dify_agent/storage/redis_run_store.py index e9f708e22de..a2a61cd9231 100644 --- a/dify-agent/src/dify_agent/storage/redis_run_store.py +++ b/dify-agent/src/dify_agent/storage/redis_run_store.py @@ -35,7 +35,7 @@ from dify_agent.runtime.event_sink import ( terminal_event_status_fields, ) from dify_agent.server.schemas import RunRecord, new_run_id -from dify_agent.server.settings import DEFAULT_RUN_RETENTION_SECONDS +from dify_agent.server.settings import DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, DEFAULT_RUN_RETENTION_SECONDS from dify_agent.storage.redis_keys import run_cancel_intent_key, run_events_key, run_record_key _TERMINAL_RUN_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"} @@ -75,7 +75,7 @@ end local ttl = tonumber(ARGV[8]) local updated_record_json = cjson.encode(record) -local event_id = redis.call("XADD", KEYS[2], "*", "payload", ARGV[7]) +local event_id = redis.call("XADD", KEYS[2], "MAXLEN", "~", ARGV[9], "*", "payload", ARGV[7]) redis.call("EXPIRE", KEYS[2], ttl) redis.call("SET", KEYS[1], updated_record_json, "EX", ttl) return {1, ARGV[1], event_id} @@ -100,7 +100,7 @@ if redis.call("EXISTS", KEYS[2]) == 1 then end local ttl = tonumber(ARGV[2]) -redis.call("XADD", KEYS[2], "*", "payload", ARGV[1]) +redis.call("XADD", KEYS[2], "MAXLEN", "1", "*", "payload", ARGV[1]) redis.call("EXPIRE", KEYS[2], ttl) redis.call("EXPIRE", KEYS[1], ttl) redis.call("EXPIRE", KEYS[3], ttl) @@ -135,7 +135,7 @@ end record.error_type = cjson.null local ttl = tonumber(ARGV[5]) -local event_id = redis.call("XADD", KEYS[3], "*", "payload", ARGV[4]) +local event_id = redis.call("XADD", KEYS[3], "MAXLEN", "~", ARGV[6], "*", "payload", ARGV[4]) redis.call("DEL", KEYS[2]) redis.call("EXPIRE", KEYS[3], ttl) redis.call("SET", KEYS[1], cjson.encode(record), "EX", ttl) @@ -147,16 +147,19 @@ class RedisRunStore(RunEventSink): """Async Redis implementation for run records and event logs. ``run_retention_seconds`` is applied to both the run record key and the - per-run Redis stream. Event writes run ``XADD`` and both TTL refreshes in one - Redis transaction so a newly created stream is not left without expiration if - the client is interrupted between commands. Event writes also refresh the - record TTL so long-running runs that keep producing events do not lose their - status record mid-run. + per-run Redis stream. Every public stream write applies an approximate + maximum length before refreshing both TTLs, so active runs cannot grow one + Redis key without bound without paying the CPU cost of exact per-entry + trimming. The transaction also ensures a newly created stream is not left + without expiration if the client is interrupted between commands. + Event writes refresh the record TTL so long-running runs that keep producing + events do not lose their status record mid-run. """ redis: Redis prefix: str run_retention_seconds: int + run_event_stream_max_length: int def __init__( self, @@ -164,12 +167,16 @@ class RedisRunStore(RunEventSink): *, prefix: str = "dify-agent", run_retention_seconds: int = DEFAULT_RUN_RETENTION_SECONDS, + run_event_stream_max_length: int = DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, ) -> None: if run_retention_seconds <= 0: raise ValueError("run_retention_seconds must be positive") + if run_event_stream_max_length <= 0: + raise ValueError("run_event_stream_max_length must be positive") self.redis = redis self.prefix = prefix self.run_retention_seconds = run_retention_seconds + self.run_event_stream_max_length = run_event_stream_max_length async def create_run(self) -> RunRecord: """Persist a running run record without storing the create request.""" @@ -199,6 +206,8 @@ class RedisRunStore(RunEventSink): _ = pipeline.xadd( events_key, {"payload": payload}, + maxlen=self.run_event_stream_max_length, + approximate=True, ) _ = pipeline.expire(events_key, self.run_retention_seconds) _ = pipeline.expire(run_record_key(self.prefix, event.run_id), self.run_retention_seconds) @@ -226,6 +235,7 @@ class RedisRunStore(RunEventSink): error_type.value if error_type is not None else "", payload, str(self.run_retention_seconds), + str(self.run_event_stream_max_length), ), ) raw_result = await evaluation @@ -316,6 +326,7 @@ class RedisRunStore(RunEventSink): error or "", payload, str(self.run_retention_seconds), + str(self.run_event_stream_max_length), ), ) result = cast(list[object], await evaluation) @@ -385,4 +396,9 @@ def _decode_redis_text(value: object) -> str: return value.decode() if isinstance(value, bytes) else str(value) -__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "RedisRunStore", "RunNotFoundError"] +__all__ = [ + "DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH", + "DEFAULT_RUN_RETENTION_SECONDS", + "RedisRunStore", + "RunNotFoundError", +] diff --git a/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py b/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py index 93a67c2e816..41deb4dfa3d 100644 --- a/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py +++ b/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py @@ -34,6 +34,7 @@ async def _run(lease, script: str, *, cwd: str) -> str: env={"HOME": lease.layout.home_dir}, timeout=30.0, max_output_bytes=4096, + mode="stdio", ) assert result.exit_code == 0 assert result.output_complete diff --git a/dify-agent/tests/integration/dify_agent/storage/test_terminal_finalization.py b/dify-agent/tests/integration/dify_agent/storage/test_terminal_finalization.py index af3a77c687d..5bf579bee03 100644 --- a/dify-agent/tests/integration/dify_agent/storage/test_terminal_finalization.py +++ b/dify-agent/tests/integration/dify_agent/storage/test_terminal_finalization.py @@ -153,6 +153,50 @@ def test_success_and_cancel_intent_commit_exactly_one_matching_terminal(redis_ur asyncio.run(scenario()) +def test_event_stream_is_trimmed_and_keeps_the_terminal_event(redis_url: str) -> None: + async def scenario() -> None: + client = Redis.from_url(redis_url) + prefix = f"bounded-events-{uuid4().hex}" + max_length = 100 + store = RedisRunStore( + client, + prefix=prefix, + run_retention_seconds=60, + run_event_stream_max_length=max_length, + ) + run_id: str | None = None + try: + record = await store.create_run() + run_id = record.run_id + for _ in range(1000): + _ = await store.append_event(RunStartedEvent(run_id=record.run_id)) + result = await store.finalize_run( + RunSucceededEvent( + run_id=record.run_id, + data=RunSucceededEventData( + output="done", + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + ) + + assert result.applied is True + stream_length = await client.xlen(run_events_key(prefix, record.run_id)) + assert max_length <= stream_length <= max_length * 2 + events = await store.get_events(record.run_id, limit=max_length * 2) + assert events.events[-1].type == "run_succeeded" + finally: + if run_id is not None: + await client.delete( + run_record_key(prefix, run_id), + run_events_key(prefix, run_id), + run_cancel_intent_key(prefix, run_id), + ) + await client.aclose() + + asyncio.run(scenario()) + + @pytest.mark.parametrize("terminal_status", ["succeeded", "failed"]) def test_terminal_first_rejects_late_cancellation(redis_url: str, terminal_status: str) -> None: async def scenario() -> None: @@ -303,6 +347,10 @@ def test_non_owner_scheduler_cancellation_stops_owner_runner(redis_url: str) -> def terminal_session_snapshot(self) -> None: return None + @property + def terminal_usage(self) -> None: + return None + async def run(self) -> None: self.started.set() try: diff --git a/dify-agent/tests/local/dify_agent/runtime/test_event_coalescer.py b/dify-agent/tests/local/dify_agent/runtime/test_event_coalescer.py new file mode 100644 index 00000000000..2ebc475e0de --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime/test_event_coalescer.py @@ -0,0 +1,227 @@ +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartEndEvent, TextPart, TextPartDelta + +from dify_agent.runtime.event_coalescer import coalesce_agent_stream_events + + +def _delta(content: str, *, index: int = 0, provider_details: dict[str, Any] | None = None) -> PartDeltaEvent: + return PartDeltaEvent( + index=index, + delta=TextPartDelta(content, provider_name="test", provider_details=provider_details), + ) + + +def _part_end(content: str = "done") -> PartEndEvent: + return PartEndEvent(index=0, part=TextPart(content)) + + +async def _events(*events: PartDeltaEvent | PartEndEvent) -> AsyncIterator[PartDeltaEvent | PartEndEvent]: + for event in events: + yield event + + +async def _collect(events: AsyncIterator[AgentStreamEvent]) -> list[AgentStreamEvent]: + return [event async for event in events] + + +def _delta_content(event: AgentStreamEvent) -> str: + assert isinstance(event, PartDeltaEvent) + assert isinstance(event.delta, TextPartDelta) + return event.delta.content_delta + + +def test_coalesces_compatible_text_deltas_and_preserves_non_text_order() -> None: + async def scenario() -> list[AgentStreamEvent]: + return await _collect( + coalesce_agent_stream_events( + _events(_delta("hel"), _delta("lo"), _part_end()), + flush_interval_seconds=10, + max_chars=100, + ) + ) + + result = asyncio.run(scenario()) + + assert len(result) == 2 + assert isinstance(result[0], PartDeltaEvent) + assert isinstance(result[0].delta, TextPartDelta) + assert result[0].delta.content_delta == "hello" + assert isinstance(result[1], PartEndEvent) + + +def test_disabled_coalescing_preserves_each_source_event() -> None: + async def scenario() -> list[AgentStreamEvent]: + return await _collect( + coalesce_agent_stream_events( + _events(_delta("hel"), _delta("lo"), _part_end()), + enabled=False, + flush_interval_seconds=10, + max_chars=100, + ) + ) + + result = asyncio.run(scenario()) + + assert len(result) == 3 + assert [_delta_content(event) for event in result[:2]] == ["hel", "lo"] + assert isinstance(result[2], PartEndEvent) + + +def test_does_not_merge_different_parts_or_provider_details() -> None: + async def scenario() -> list[AgentStreamEvent]: + return await _collect( + coalesce_agent_stream_events( + _events( + _delta("a", index=0), + _delta("b", index=1), + _delta("c", index=1, provider_details={"token": 1}), + ), + flush_interval_seconds=10, + max_chars=100, + ) + ) + + result = asyncio.run(scenario()) + + assert [_delta_content(event) for event in result] == ["a", "b", "c"] + + +def test_flushes_when_character_limit_is_reached() -> None: + async def scenario() -> list[AgentStreamEvent]: + return await _collect( + coalesce_agent_stream_events( + _events(_delta("ab"), _delta("cd"), _delta("ef")), + flush_interval_seconds=10, + max_chars=4, + ) + ) + + result = asyncio.run(scenario()) + + assert [_delta_content(event) for event in result] == ["abcd", "ef"] + + +def test_high_volume_text_deltas_are_reduced_to_size_bounded_batches() -> None: + async def scenario() -> list[AgentStreamEvent]: + async def many_deltas() -> AsyncIterator[AgentStreamEvent]: + for _ in range(20_000): + yield _delta("x") + + return await _collect( + coalesce_agent_stream_events( + many_deltas(), + flush_interval_seconds=10, + max_chars=4096, + ) + ) + + result = asyncio.run(scenario()) + + assert len(result) == 5 + assert sum(len(_delta_content(event)) for event in result) == 20_000 + + +def test_flushes_on_deadline_while_source_is_idle() -> None: + async def scenario() -> tuple[PartDeltaEvent, PartEndEvent]: + release_source = asyncio.Event() + + async def delayed_events() -> AsyncIterator[PartDeltaEvent | PartEndEvent]: + yield _delta("ready") + await release_source.wait() + yield _part_end() + + events = coalesce_agent_stream_events( + delayed_events(), + flush_interval_seconds=0.01, + max_chars=100, + ) + first = await asyncio.wait_for(anext(events), timeout=0.2) + release_source.set() + second = await asyncio.wait_for(anext(events), timeout=0.2) + with pytest.raises(StopAsyncIteration): + _ = await anext(events) + assert isinstance(first, PartDeltaEvent) + assert isinstance(second, PartEndEvent) + return first, second + + first, second = asyncio.run(scenario()) + + assert isinstance(first.delta, TextPartDelta) + assert first.delta.content_delta == "ready" + assert isinstance(second.part, TextPart) + assert second.part.content == "done" + + +def test_flushes_buffer_before_propagating_source_failure() -> None: + async def scenario() -> PartDeltaEvent: + async def failing_events() -> AsyncIterator[AgentStreamEvent]: + yield _delta("partial") + raise RuntimeError("stream failed") + + events = coalesce_agent_stream_events( + failing_events(), + flush_interval_seconds=10, + max_chars=100, + ) + first = await anext(events) + with pytest.raises(RuntimeError, match="stream failed"): + _ = await anext(events) + assert isinstance(first, PartDeltaEvent) + return first + + first = asyncio.run(scenario()) + + assert isinstance(first.delta, TextPartDelta) + assert first.delta.content_delta == "partial" + + +def test_flushes_buffer_before_consumer_cancellation() -> None: + async def scenario() -> list[AgentStreamEvent]: + source_waiting = asyncio.Event() + emitted: list[AgentStreamEvent] = [] + + async def blocked_events() -> AsyncIterator[AgentStreamEvent]: + yield _delta("partial") + source_waiting.set() + await asyncio.Event().wait() + + async def consume() -> None: + async for event in coalesce_agent_stream_events( + blocked_events(), + flush_interval_seconds=10, + max_chars=100, + ): + emitted.append(event) + + task = asyncio.create_task(consume()) + await asyncio.wait_for(source_waiting.wait(), timeout=0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return emitted + + emitted = asyncio.run(scenario()) + + assert len(emitted) == 1 + assert _delta_content(emitted[0]) == "partial" + + +@pytest.mark.parametrize( + ("flush_interval_seconds", "max_chars", "message"), + [(-0.1, 100, "positive"), (0, 100, "positive"), (0.1, 0, "positive")], +) +def test_rejects_invalid_bounds(flush_interval_seconds: float, max_chars: int, message: str) -> None: + async def scenario() -> None: + events = coalesce_agent_stream_events( + _events(_delta("a")), + flush_interval_seconds=flush_interval_seconds, + max_chars=max_chars, + ) + with pytest.raises(ValueError, match=message): + _ = await anext(events) + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py index 45e788d0c7e..7c00424f243 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py @@ -461,7 +461,7 @@ class FinalizeSuccessOnCancellationRunner(SnapshotlessRunner): assert result.applied is True -def test_default_runner_factory_passes_run_timeout_to_runner() -> None: +def test_default_runner_factory_passes_runtime_limits_to_runner() -> None: async def scenario() -> None: store = FakeStore() record = await store.create_run() @@ -471,12 +471,18 @@ def test_default_runner_factory_passes_run_timeout_to_runner() -> None: plugin_daemon_http_client=client, dify_api_http_client=client, run_timeout_seconds=17, + stream_text_delta_coalescing_enabled=False, + stream_text_delta_flush_interval_seconds=0.25, + stream_text_delta_max_chars=2048, ) runner = scheduler._default_runner_factory(record, _request(), is_cancelled=lambda: False) assert isinstance(runner, AgentRunRunner) assert runner.run_timeout_seconds == 17 + assert runner.stream_text_delta_coalescing_enabled is False + assert runner.stream_text_delta_flush_interval_seconds == 0.25 + assert runner.stream_text_delta_max_chars == 2048 asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 14d49f9f3be..7fa209c8bd1 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -69,6 +69,9 @@ class FakeRunScheduler: store: object shutdown_grace_seconds: float run_timeout_seconds: float + stream_text_delta_coalescing_enabled: bool + stream_text_delta_flush_interval_seconds: float + stream_text_delta_max_chars: int layer_providers: tuple[DifyAgentLayerProvider, ...] plugin_daemon_http_client: FakePluginDaemonHttpClient dify_api_http_client: FakePluginDaemonHttpClient @@ -82,11 +85,17 @@ class FakeRunScheduler: dify_api_http_client: FakePluginDaemonHttpClient, shutdown_grace_seconds: float, run_timeout_seconds: float, + stream_text_delta_coalescing_enabled: bool, + stream_text_delta_flush_interval_seconds: float, + stream_text_delta_max_chars: int, layer_providers: tuple[DifyAgentLayerProvider, ...], ) -> None: self.store = store self.shutdown_grace_seconds = shutdown_grace_seconds self.run_timeout_seconds = run_timeout_seconds + self.stream_text_delta_coalescing_enabled = stream_text_delta_coalescing_enabled + self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds + self.stream_text_delta_max_chars = stream_text_delta_max_chars self.layer_providers = layer_providers self.plugin_daemon_http_client = plugin_daemon_http_client self.dify_api_http_client = dify_api_http_client @@ -203,6 +212,10 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt shutdown_grace_seconds=5, run_timeout_seconds=17, run_retention_seconds=7, + run_event_stream_max_length=23, + stream_text_delta_coalescing_enabled=False, + stream_text_delta_flush_interval_ms=250, + stream_text_delta_max_chars=2048, plugin_daemon_url="http://plugin-daemon", plugin_daemon_api_key="daemon-secret", inner_api_url="http://dify-api", @@ -226,6 +239,9 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt scheduler = FakeRunScheduler.created[0] assert scheduler.shutdown_grace_seconds == 5 assert scheduler.run_timeout_seconds == 17 + assert scheduler.stream_text_delta_coalescing_enabled is False + assert scheduler.stream_text_delta_flush_interval_seconds == 0.25 + assert scheduler.stream_text_delta_max_chars == 2048 layer_providers = scheduler.layer_providers assert isinstance(layer_providers, tuple) execution_context_provider = next( @@ -284,6 +300,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt store = scheduler.store assert isinstance(store, RedisRunStore) assert store.run_retention_seconds == 7 + assert store.run_event_stream_max_length == 23 assert any(getattr(route, "path", None) == "/agent-stub/connections" for route in create_app(settings).routes) assert any( getattr(route, "path", None) == "/agent-stub/files/upload-request" for route in create_app(settings).routes diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index b94dc033122..465c6a3b304 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -8,7 +8,11 @@ from pydantic import ValidationError from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec -from dify_agent.server.settings import ServerSettings +from dify_agent.server.settings import ( + DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, + DEFAULT_RUN_RETENTION_SECONDS, + ServerSettings, +) from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS, E2BExecutionBindingBackend from dify_agent.runtime_backend.enterprise import EnterpriseExecutionBindingBackend, EnterpriseHomeSnapshotBackend @@ -79,6 +83,53 @@ def test_server_settings_rejects_non_positive_run_timeout() -> None: _ = ServerSettings(run_timeout_seconds=0) +def test_server_settings_defaults_to_bounded_short_lived_run_events( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("DIFY_AGENT_RUN_RETENTION_SECONDS", raising=False) + monkeypatch.delenv("DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH", raising=False) + monkeypatch.delenv("DIFY_AGENT_STREAM_TEXT_DELTA_COALESCING_ENABLED", raising=False) + monkeypatch.delenv("DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS", raising=False) + monkeypatch.delenv("DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS", raising=False) + monkeypatch.chdir(tmp_path) + + settings = ServerSettings() + + assert settings.run_retention_seconds == DEFAULT_RUN_RETENTION_SECONDS == 7200 + assert settings.run_event_stream_max_length == DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH == 5000 + assert settings.stream_text_delta_coalescing_enabled is True + assert settings.stream_text_delta_flush_interval_ms == 100 + assert settings.stream_text_delta_max_chars == 4096 + + +def test_server_settings_reads_run_event_bounds_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH", "1234") + monkeypatch.setenv("DIFY_AGENT_STREAM_TEXT_DELTA_COALESCING_ENABLED", "false") + monkeypatch.setenv("DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS", "250") + monkeypatch.setenv("DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS", "2048") + + settings = ServerSettings() + + assert settings.run_event_stream_max_length == 1234 + assert settings.stream_text_delta_coalescing_enabled is False + assert settings.stream_text_delta_flush_interval_ms == 250 + assert settings.stream_text_delta_max_chars == 2048 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("run_event_stream_max_length", 0), + ("stream_text_delta_flush_interval_ms", 0), + ("stream_text_delta_max_chars", 0), + ], +) +def test_server_settings_rejects_invalid_run_event_bounds(field: str, value: int) -> None: + with pytest.raises(ValidationError): + _ = ServerSettings(**{field: value}) # pyright: ignore[reportArgumentType] + + def test_server_settings_reads_binding_file_download_command_timeout_from_env( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py b/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py index a39b960c7cc..68641aa7a10 100644 --- a/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py +++ b/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py @@ -24,7 +24,12 @@ from dify_agent.protocol.schemas import ( ) from dify_agent.runtime.cancellation import RunCancellationIntent from dify_agent.runtime.event_sink import RunFinalizationResult -from dify_agent.storage.redis_run_store import DEFAULT_RUN_RETENTION_SECONDS, RedisRunStore, RunNotFoundError +from dify_agent.storage.redis_run_store import ( + DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, + DEFAULT_RUN_RETENTION_SECONDS, + RedisRunStore, + RunNotFoundError, +) class FakeRedis: @@ -48,18 +53,34 @@ class FakeRedis: self.commands.append(("get", key)) return self.values.get(key) - async def xadd(self, key: str, fields: Mapping[str, object]) -> str: - self.commands.append(("xadd", key, dict(fields))) - return self._append_stream_entry(key, fields) + async def xadd( + self, + key: str, + fields: Mapping[str, object], + *, + maxlen: int | None = None, + approximate: bool = True, + ) -> str: + self.commands.append(("xadd", key, dict(fields), maxlen, approximate)) + return self._append_stream_entry(key, fields, maxlen=maxlen) def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> "FakeRedisPipeline": self.commands.append(("pipeline", transaction, shard_hint)) return FakeRedisPipeline(self) - def _append_stream_entry(self, key: str, fields: Mapping[str, object]) -> str: + def _append_stream_entry( + self, + key: str, + fields: Mapping[str, object], + *, + maxlen: int | None = None, + ) -> str: entries = self.streams.setdefault(key, []) - event_id = f"{len(entries) + 1}-0" + next_sequence = self._stream_id_value(entries[-1][0])[0] + 1 if entries else 1 + event_id = f"{next_sequence}-0" entries.append((event_id, dict(fields))) + if maxlen is not None and len(entries) > maxlen: + del entries[: len(entries) - maxlen] self.stream_changed.set() return event_id @@ -137,9 +158,16 @@ class FakeRedisPipeline: async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: del exc_type, exc, traceback - def xadd(self, key: str, fields: Mapping[str, object]) -> "FakeRedisPipeline": - self.redis.commands.append(("xadd", key, dict(fields))) - self.results.append(self.redis._append_stream_entry(key, fields)) + def xadd( + self, + key: str, + fields: Mapping[str, object], + *, + maxlen: int | None = None, + approximate: bool = True, + ) -> "FakeRedisPipeline": + self.redis.commands.append(("xadd", key, dict(fields), maxlen, approximate)) + self.results.append(self.redis._append_stream_entry(key, fields, maxlen=maxlen)) return self def expire(self, key: str, seconds: int) -> "FakeRedisPipeline": @@ -184,6 +212,15 @@ def test_create_run_writes_running_record_without_job_queue_and_with_retention() assert "request" not in str(redis.commands[0][2]) +@pytest.mark.parametrize( + "kwargs", + [{"run_retention_seconds": 0}, {"run_event_stream_max_length": 0}], +) +def test_rejects_non_positive_run_storage_bounds(kwargs: dict[str, int]) -> None: + with pytest.raises(ValueError, match="must be positive"): + _ = RedisRunStore(FakeRedis(), **kwargs) # pyright: ignore[reportArgumentType] + + def test_get_run_accepts_legacy_record_without_error_type() -> None: redis = FakeRedis() store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType] @@ -223,6 +260,7 @@ def test_request_cancellation_maps_eval_result_and_arguments() -> None: assert intent_payload["reason"] == "workflow_aborted" assert intent_payload["message"] == "workflow stopped" assert eval_command[7] == "60" + assert '"MAXLEN", "1"' in cast(str, eval_command[1]) def test_finalize_cancellation_maps_eval_result_and_arguments() -> None: @@ -262,6 +300,8 @@ def test_finalize_cancellation_maps_eval_result_and_arguments() -> None: assert payload["data"]["usage"]["completion_tokens"] == 8 assert payload["data"]["usage"]["total_tokens"] == 21 assert eval_command[10] == "60" + assert eval_command[11] == str(DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH) + assert '"MAXLEN", "~", ARGV[6]' in cast(str, eval_command[1]) def test_finalize_failed_run_maps_eval_result_and_arguments() -> None: @@ -290,6 +330,9 @@ def test_finalize_failed_run_maps_eval_result_and_arguments() -> None: assert eval_command[8:12] == ("1", "model failed", "1", "agent_run_limit_exceeded") payload = json.loads(cast(str, eval_command[12])) assert payload["data"]["error_type"] == "agent_run_limit_exceeded" + assert eval_command[13] == str(DEFAULT_RUN_RETENTION_SECONDS) + assert eval_command[14] == str(DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH) + assert '"MAXLEN", "~", ARGV[9]' in cast(str, eval_command[1]) def test_request_cancellation_raises_when_record_is_missing() -> None: @@ -368,6 +411,7 @@ def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() - assert isinstance(fields, dict) assert '"id"' not in str(fields["payload"]) assert '"type":"run_started"' in str(fields["payload"]) + assert xadd_commands[0][3:] == (DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, True) expire_commands = {command for command in redis.commands if command[0] == "expire"} assert expire_commands == { ("expire", "test:runs:run-1:events", 60), @@ -376,6 +420,26 @@ def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() - assert ("execute",) in redis.commands +def test_append_event_keeps_only_the_configured_number_of_entries() -> None: + redis = FakeRedis() + store = RedisRunStore( + redis, # pyright: ignore[reportArgumentType] + prefix="test", + run_retention_seconds=60, + run_event_stream_max_length=2, + ) + + async def scenario() -> None: + for _ in range(3): + _ = await store.append_event(RunStartedEvent(run_id="run-1")) + + asyncio.run(scenario()) + + assert len(redis.streams["test:runs:run-1:events"]) == 2 + xadd_commands = [command for command in redis.commands if command[0] == "xadd"] + assert all(command[3:] == (2, True) for command in xadd_commands) + + def test_get_events_round_trips_run_succeeded_output_and_session_snapshot() -> None: redis = FakeRedis() store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType] diff --git a/docker/envs/core-services/dify-agent.env.example b/docker/envs/core-services/dify-agent.env.example index e4ab19342a3..c7ac2458c4e 100644 --- a/docker/envs/core-services/dify-agent.env.example +++ b/docker/envs/core-services/dify-agent.env.example @@ -8,7 +8,11 @@ AGENT_BACKEND_BASE_URL=http://agent_backend:5050 DIFY_AGENT_REDIS_URL= DIFY_AGENT_REDIS_PREFIX=dify-agent DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30 -DIFY_AGENT_RUN_RETENTION_SECONDS=259200 +DIFY_AGENT_RUN_RETENTION_SECONDS=7200 +DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH=5000 +DIFY_AGENT_STREAM_TEXT_DELTA_COALESCING_ENABLED=true +DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS=100 +DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS=4096 # Internal deadline for the Agent Backend model/tool loop. The effective run # limit is whichever expires first: this value or the relevant API/Workflow # outer execution limit. To allow every path to run for one hour, also set diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 240d77c1be8..73e70c710bb 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -17,6 +17,7 @@ Run commands from the repository root. Install dependencies and browsers once wi - Prepare and run external runtime scenarios: `E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external` - Seed against existing middleware without running Cucumber: `pnpm -C e2e seed -- --profile ` - Reset persisted E2E state: `pnpm -C e2e e2e:reset` +- Build the production Web artifact without starting services: `pnpm -C e2e e2e:web:build` - Middleware lifecycle: `pnpm -C e2e e2e:middleware:up` and `pnpm -C e2e e2e:middleware:down` - Scoped static checks: `vp check e2e` diff --git a/e2e/package.json b/e2e/package.json index 871766c9714..9c37769c1ce 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -15,6 +15,7 @@ "e2e:install": "playwright install --with-deps chromium webkit", "e2e:install:ci": "playwright install --with-deps --only-shell chromium webkit", "e2e:install:ci:chromium": "playwright install --with-deps --only-shell chromium", + "e2e:install:ci:webkit": "playwright install --with-deps --only-shell webkit", "e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down", "e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up", "e2e:post-merge": "tsx ./scripts/run-post-merge.ts", @@ -22,6 +23,7 @@ "e2e:prepared": "tsx ./scripts/run-prepared.ts", "e2e:prepared:prepare": "tsx ./scripts/run-cucumber.ts --seed-only --profile prepared", "e2e:reset": "tsx ./scripts/setup.ts reset", + "e2e:web:build": "tsx ./scripts/setup.ts web-build", "seed": "tsx ./scripts/run-cucumber.ts --seed-only", "test:unit": "vp test run", "type-check": "tsc" diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index 5aa99884560..6caf27435ac 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -569,7 +569,7 @@ export const startMiddleware = async () => { const printUsage = () => { console.log( - 'Usage: tsx ./scripts/setup.ts ', + 'Usage: tsx ./scripts/setup.ts ', ) } @@ -603,6 +603,9 @@ const main = async () => { case 'web': await startWeb() return + case 'web-build': + await ensureWebBuild() + return default: printUsage() process.exitCode = 1 diff --git a/lint.config.ts b/lint.config.ts index a5239e811f5..3ca800f1167 100644 --- a/lint.config.ts +++ b/lint.config.ts @@ -1118,6 +1118,19 @@ export const lintConfig = { 'eslint-react/use-memo': 'error', }, }, + { + files: ['web/**/*.{jsx,tsx}'], + excludeFiles: [ + 'web/**/__tests__/**', + 'web/**/*.spec.{jsx,tsx}', + 'web/**/*.test.{jsx,tsx}', + 'web/**/*.stories.{jsx,tsx}', + 'web/**/*.story.{jsx,tsx}', + ], + rules: { + 'dify/require-title-for-truncated-text': 'warn', + }, + }, { files: [ 'web/**/__tests__/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index f2c535172a1..ee07940c105 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -392,9 +392,6 @@ "jsx-a11y/no-static-element-interactions": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 1 } @@ -2217,9 +2214,6 @@ "web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx": { "eslint-react/set-state-in-effect": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx": { @@ -2613,9 +2607,6 @@ }, "jsx-a11y/no-static-element-interactions": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/plugins/plugin-auth/hooks/use-get-api.ts": { @@ -3840,11 +3831,6 @@ "count": 4 } }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -4336,16 +4322,6 @@ "count": 4 } }, - "web/app/components/workflow/nodes/trigger-schedule/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx": { "typescript/no-non-null-asserted-optional-chain": { "count": 1 diff --git a/package.json b/package.json index fe00efc977c..8caf1aca815 100644 --- a/package.json +++ b/package.json @@ -57,12 +57,12 @@ "devEngines": { "runtime": { "name": "node", - "version": "^22.22.1", + "version": "24.20.0", "onFail": "download" } }, "engines": { - "node": "^22.22.1" + "node": "^24.20.0" }, - "packageManager": "pnpm@11.23.0" + "packageManager": "pnpm@11.25.0" } diff --git a/packages/dev-proxy/package.json b/packages/dev-proxy/package.json index d249d0cf50f..ebdec46de78 100644 --- a/packages/dev-proxy/package.json +++ b/packages/dev-proxy/package.json @@ -39,6 +39,6 @@ "vitest": "catalog:" }, "engines": { - "node": "^22.22.1" + "node": "^24.20.0" } } diff --git a/packages/dev-proxy/vite.config.ts b/packages/dev-proxy/vite.config.ts index 2208c5108a6..d459331d695 100644 --- a/packages/dev-proxy/vite.config.ts +++ b/packages/dev-proxy/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ outDir: 'dist', platform: 'node', sourcemap: true, - target: 'node22', + target: 'node24', treeshake: true, }, test: { diff --git a/packages/migrate-no-unchecked-indexed-access/vite.config.ts b/packages/migrate-no-unchecked-indexed-access/vite.config.ts index ac4aed1a064..2e167da96a0 100644 --- a/packages/migrate-no-unchecked-indexed-access/vite.config.ts +++ b/packages/migrate-no-unchecked-indexed-access/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ outDir: 'dist', platform: 'node', sourcemap: true, - target: 'node22', + target: 'node24', treeshake: true, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb431d555d6..a4fa00b997f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,11 +7,11 @@ settings: catalogs: default: '@amplitude/analytics-browser': - specifier: 2.45.6 - version: 2.45.6 + specifier: 2.45.8 + version: 2.45.8 '@amplitude/plugin-session-replay-browser': - specifier: 1.33.8 - version: 1.33.8 + specifier: 1.35.0 + version: 1.35.0 '@axe-core/playwright': specifier: 4.13.0 version: 4.13.0 @@ -82,8 +82,8 @@ catalogs: specifier: 0.47.0 version: 0.47.0 '@mediabunny/mp3-encoder': - specifier: 1.55.2 - version: 1.55.2 + specifier: 1.55.5 + version: 1.55.5 '@monaco-editor/react': specifier: 4.7.0 version: 4.7.0 @@ -112,8 +112,8 @@ catalogs: specifier: 4.2.3 version: 4.2.3 '@sentry/react': - specifier: 10.70.0 - version: 10.70.0 + specifier: 10.73.0 + version: 10.73.0 '@storybook/addon-a11y': specifier: 10.5.10 version: 10.5.10 @@ -160,14 +160,14 @@ catalogs: specifier: 4.3.3 version: 4.3.3 '@tanstack/eslint-plugin-query': - specifier: 5.102.2 - version: 5.102.2 + specifier: 5.102.8 + version: 5.102.8 '@tanstack/form-core': specifier: 1.33.5 version: 1.33.5 '@tanstack/query-core': - specifier: 5.102.2 - version: 5.102.2 + specifier: 5.102.8 + version: 5.102.8 '@tanstack/react-form': specifier: 1.33.5 version: 1.33.5 @@ -175,8 +175,8 @@ catalogs: specifier: 0.10.0 version: 0.10.0 '@tanstack/react-query': - specifier: 5.102.2 - version: 5.102.2 + specifier: 5.102.8 + version: 5.102.8 '@tanstack/react-virtual': specifier: 3.14.10 version: 3.14.10 @@ -187,8 +187,8 @@ catalogs: specifier: 6.9.1 version: 6.9.1 '@testing-library/react': - specifier: 16.3.2 - version: 16.3.2 + specifier: 16.3.3 + version: 16.3.3 '@testing-library/user-event': specifier: 14.6.6 version: 14.6.6 @@ -226,14 +226,14 @@ catalogs: specifier: 1.15.9 version: 1.15.9 '@typescript-eslint/parser': - specifier: 8.67.0 - version: 8.67.0 + specifier: 8.69.0 + version: 8.69.0 '@typescript/native': specifier: npm:typescript@7.0.2 version: 7.0.2 '@vitejs/plugin-react': - specifier: 6.1.0 - version: 6.1.0 + specifier: 6.1.1 + version: 6.1.1 '@vitejs/plugin-rsc': specifier: 0.5.34 version: 0.5.34 @@ -310,11 +310,11 @@ catalogs: specifier: 5.6.0 version: 5.6.0 es-toolkit: - specifier: 1.51.0 - version: 1.51.0 + specifier: 1.52.0 + version: 1.52.0 eslint: - specifier: 10.9.0 - version: 10.9.0 + specifier: 10.9.1 + version: 10.9.1 eslint-markdown: specifier: 0.12.1 version: 0.12.1 @@ -346,8 +346,8 @@ catalogs: specifier: 1.3.1 version: 1.3.1 eslint-plugin-perfectionist: - specifier: 5.10.1 - version: 5.10.1 + specifier: 5.11.0 + version: 5.11.0 eslint-plugin-pnpm: specifier: 1.8.0 version: 1.8.0 @@ -373,20 +373,20 @@ catalogs: specifier: 3.1.3 version: 3.1.3 foxact: - specifier: 0.3.9 - version: 0.3.9 + specifier: 0.3.10 + version: 0.3.10 fuse.js: specifier: 7.5.0 version: 7.5.0 happy-dom: - specifier: 20.11.6 - version: 20.11.6 + specifier: 20.12.0 + version: 20.12.0 hast-util-to-jsx-runtime: specifier: 2.3.6 version: 2.3.6 hono: - specifier: 4.13.3 - version: 4.13.3 + specifier: 4.13.5 + version: 4.13.5 html-entities: specifier: 2.6.0 version: 2.6.0 @@ -406,8 +406,8 @@ catalogs: specifier: 11.1.18 version: 11.1.18 jotai: - specifier: 2.20.2 - version: 2.20.2 + specifier: 2.20.3 + version: 2.20.3 jotai-scope: specifier: 0.11.0 version: 0.11.0 @@ -418,8 +418,8 @@ catalogs: specifier: 3.0.8 version: 3.0.8 js-yaml: - specifier: 5.3.0 - version: 5.3.0 + specifier: 5.4.1 + version: 5.4.1 jsonschema: specifier: 1.5.0 version: 1.5.0 @@ -427,11 +427,11 @@ catalogs: specifier: 0.17.0 version: 0.17.0 knip: - specifier: 6.32.2 - version: 6.32.2 + specifier: 6.34.0 + version: 6.34.0 ky: - specifier: 2.0.2 - version: 2.0.2 + specifier: 2.1.0 + version: 2.1.0 lexical: specifier: 0.47.0 version: 0.47.0 @@ -439,14 +439,14 @@ catalogs: specifier: 1.0.4 version: 1.0.4 loro-crdt: - specifier: 1.14.1 - version: 1.14.1 + specifier: 1.15.1 + version: 1.15.1 mediabunny: - specifier: 1.55.2 - version: 1.55.2 + specifier: 1.55.5 + version: 1.55.5 mermaid: - specifier: 11.17.0 - version: 11.17.0 + specifier: 11.17.2 + version: 11.17.2 mime: specifier: 4.1.0 version: 4.1.0 @@ -460,17 +460,17 @@ catalogs: specifier: 1.1.0 version: 1.1.0 next: - specifier: 16.3.3 - version: 16.3.3 + specifier: 16.3.4 + version: 16.3.4 next-themes: specifier: 0.4.6 version: 0.4.6 nuqs: - specifier: 2.10.0 - version: 2.10.0 + specifier: 2.10.1 + version: 2.10.1 open: - specifier: 11.0.1 - version: 11.0.1 + specifier: 11.0.2 + version: 11.0.2 ora: specifier: 9.4.1 version: 9.4.1 @@ -490,8 +490,8 @@ catalogs: specifier: 4.2.0 version: 4.2.0 qs: - specifier: 6.15.3 - version: 6.15.3 + specifier: 6.16.0 + version: 6.16.0 react: specifier: 19.2.8 version: 19.2.8 @@ -535,8 +535,8 @@ catalogs: specifier: 0.0.1 version: 0.0.1 shiki: - specifier: 4.4.2 - version: 4.4.2 + specifier: 4.4.3 + version: 4.4.3 socket.io-client: specifier: 4.8.3 version: 4.8.3 @@ -550,8 +550,8 @@ catalogs: specifier: 10.5.10 version: 10.5.10 streamdown: - specifier: 2.5.0 - version: 2.5.0 + specifier: 2.6.0 + version: 2.6.0 string-ts: specifier: 2.3.1 version: 2.3.1 @@ -562,11 +562,11 @@ catalogs: specifier: 4.3.3 version: 4.3.3 tldts: - specifier: 7.4.10 - version: 7.4.10 + specifier: 7.4.11 + version: 7.4.11 tsx: - specifier: 4.23.12 - version: 4.23.12 + specifier: 4.23.13 + version: 4.23.13 typescript: specifier: npm:@typescript/typescript6@6.0.2 version: 6.0.2 @@ -607,8 +607,8 @@ catalogs: specifier: 1.1.5 version: 1.1.5 zod: - specifier: 4.4.3 - version: 4.4.3 + specifier: 4.5.4 + version: 4.5.4 zundo: specifier: 2.3.0 version: 2.3.0 @@ -625,11 +625,11 @@ overrides: esbuild@>=0.27.3 <0.28.1: ^0.28.2 is-core-module: npm:@nolyfill/is-core-module@^1.0.39 js-yaml@<=4.1.1: ^4.1.2 - picomatch@>=4.0.0 <4.0.4: 4.0.5 + picomatch@>=4.0.0 <4.0.4: 4.0.7 postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4 postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.5 postcss@<8.5.10: ^8.5.10 - rollup@>=4.0.0 <4.59.0: 4.62.5 + rollup@>=4.0.0 <4.59.0: 4.63.1 safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 solid-js: 1.9.15 @@ -647,10 +647,10 @@ importers: devDependencies: '@eslint-community/eslint-plugin-eslint-comments': specifier: 'catalog:' - version: 4.7.2(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 4.7.2(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) '@eslint-react/eslint-plugin': specifier: 'catalog:' - version: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) '@eslint/markdown': specifier: 'catalog:' version: 8.0.3(supports-color@11.0.0) @@ -665,13 +665,13 @@ importers: version: 1.2.10 '@tanstack/eslint-plugin-query': specifier: 'catalog:' - version: 5.102.2(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 5.102.8(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) '@types/node': specifier: 'catalog:' version: 25.9.5 '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) '@typescript/native': specifier: 'catalog:' version: typescript@7.0.2 @@ -680,73 +680,73 @@ importers: version: 10.0.5 eslint: specifier: 'catalog:' - version: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + version: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) eslint-markdown: specifier: 'catalog:' - version: 0.12.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 0.12.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-antfu: specifier: 'catalog:' - version: 3.2.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 3.2.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' - version: 4.7.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(tailwindcss@4.3.3) + version: 4.7.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(tailwindcss@4.3.3) eslint-plugin-command: specifier: 'catalog:' - version: 3.5.3(@typescript-eslint/typescript-estree@8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 3.5.3(@typescript-eslint/typescript-estree@8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-erasable-syntax-only: specifier: 'catalog:' - version: 0.4.2(@typescript-eslint/parser@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 0.4.2(@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-jsdoc: specifier: 'catalog:' - version: 63.3.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 63.3.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-jsonc: specifier: 'catalog:' - version: 3.4.2(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 3.4.2(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-markdown-preferences: specifier: 'catalog:' - version: 0.41.1(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 0.41.1(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-n: specifier: 'catalog:' - version: 18.3.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 18.3.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-no-barrel-files: specifier: 'catalog:' - version: 1.3.1(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 1.3.1(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-perfectionist: specifier: 'catalog:' - version: 5.10.1(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 5.11.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-pnpm: specifier: 'catalog:' - version: 1.8.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 1.8.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-regexp: specifier: 'catalog:' - version: 3.1.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 3.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.5.10(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 10.5.10(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-toml: specifier: 'catalog:' - version: 1.5.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 1.5.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-unicorn: specifier: 'catalog:' - version: 71.1.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 71.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-yml: specifier: 'catalog:' - version: 3.8.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 3.8.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) knip: specifier: 'catalog:' - version: 6.32.2 + version: 6.34.0 node: - specifier: runtime:^22.22.1 - version: runtime:22.23.2 + specifier: runtime:24.20.0 + version: runtime:24.20.0 typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) cli: dependencies: @@ -773,13 +773,13 @@ importers: version: 3.1.1 js-yaml: specifier: 'catalog:' - version: 5.3.0 + version: 5.4.1 lockfile: specifier: 'catalog:' version: 1.0.4 open: specifier: 'catalog:' - version: 11.0.1 + version: 11.0.2 ora: specifier: 'catalog:' version: 9.4.1 @@ -794,14 +794,14 @@ importers: version: 7.29.0 zod: specifier: 'catalog:' - version: 4.4.3 + version: 4.5.4 devDependencies: '@dify/tsconfig': specifier: workspace:* version: link:../packages/tsconfig '@hono/node-server': specifier: 'catalog:' - version: 2.1.1(hono@4.13.3) + version: 2.1.1(hono@4.13.5) '@types/lockfile': specifier: 'catalog:' version: 1.0.4 @@ -816,19 +816,19 @@ importers: version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) hono: specifier: 'catalog:' - version: 4.13.3 + version: 4.13.5 typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) e2e: devDependencies: @@ -858,7 +858,7 @@ importers: version: 1.62.1 '@t3-oss/env-core': specifier: 'catalog:' - version: 0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.4.3) + version: 0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.5.4) '@types/node': specifier: 'catalog:' version: 25.9.5 @@ -870,22 +870,22 @@ importers: version: 3.1.1 tsx: specifier: 'catalog:' - version: 4.23.12 + version: 4.23.13 typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) zod: specifier: 'catalog:' - version: 4.4.3 + version: 4.5.4 packages/contracts: dependencies: @@ -894,7 +894,7 @@ importers: version: 1.15.0 zod: specifier: 'catalog:' - version: 4.4.3 + version: 4.5.4 devDependencies: '@dify/tsconfig': specifier: workspace:* @@ -910,25 +910,25 @@ importers: version: typescript@7.0.2 js-yaml: specifier: 'catalog:' - version: 5.3.0 + version: 5.4.1 typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) packages/dev-proxy: dependencies: '@hono/node-server': specifier: 'catalog:' - version: 2.1.1(hono@4.13.3) + version: 2.1.1(hono@4.13.5) c12: specifier: 'catalog:' version: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.0)(jiti@2.7.0)(magicast@0.5.3) @@ -937,7 +937,7 @@ importers: version: 5.0.0 hono: specifier: 'catalog:' - version: 4.13.3 + version: 4.13.5 devDependencies: '@dify/tsconfig': specifier: workspace:* @@ -950,13 +950,13 @@ importers: version: typescript@7.0.2 vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.12.0) packages/dify-ui: dependencies: @@ -972,7 +972,7 @@ importers: version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.3.0(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 5.3.0(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@dify/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -984,25 +984,25 @@ importers: version: 1.2.10 '@storybook/addon-a11y': specifier: 'catalog:' - version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-docs': specifier: 'catalog:' - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-links': specifier: 'catalog:' - version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(vitest@4.1.11) + version: 10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(vitest@4.1.11) '@storybook/react-vite': specifier: 'catalog:' - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@tanstack/react-hotkeys': specifier: 'catalog:' version: 0.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1020,10 +1020,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 6.1.1(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/browser-playwright': specifier: 'catalog:' - version: 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + version: 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) @@ -1041,7 +1041,7 @@ importers: version: 19.2.8(react@19.2.8) storybook: specifier: 'catalog:' - version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) tailwindcss: specifier: 'catalog:' version: 4.3.3 @@ -1050,13 +1050,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) vitest-browser-react: specifier: 'catalog:' version: 2.2.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11) @@ -1068,7 +1068,7 @@ importers: version: 0.2.0(supports-color@11.0.0) tsx: specifier: 'catalog:' - version: 4.23.12 + version: 4.23.13 packages/jotai-tanstack-form: devDependencies: @@ -1083,19 +1083,19 @@ importers: version: typescript@7.0.2 jotai: specifier: 'catalog:' - version: 2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + version: 2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) packages/migrate-no-unchecked-indexed-access: dependencies: @@ -1114,10 +1114,10 @@ importers: version: 25.9.5 vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) packages/nuqs-jotai: devDependencies: @@ -1126,7 +1126,7 @@ importers: version: link:../tsconfig '@testing-library/react': specifier: 'catalog:' - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/user-event': specifier: 'catalog:' version: 14.6.6(@testing-library/dom@10.4.1) @@ -1138,16 +1138,16 @@ importers: version: typescript@7.0.2 happy-dom: specifier: 'catalog:' - version: 20.11.6 + version: 20.12.0 jotai: specifier: 'catalog:' - version: 2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + version: 2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) jotai-scope: specifier: 'catalog:' - version: 0.11.0(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + version: 0.11.0(jotai@2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) nuqs: specifier: 'catalog:' - version: 2.10.0(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 2.10.1(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) react: specifier: 'catalog:' version: 19.2.8 @@ -1159,13 +1159,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) packages/tsconfig: {} @@ -1188,22 +1188,22 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) web: dependencies: '@amplitude/analytics-browser': specifier: 'catalog:' - version: 2.45.6 + version: 2.45.8 '@amplitude/plugin-session-replay-browser': specifier: 'catalog:' - version: 1.33.8(@amplitude/rrweb@2.1.1) + version: 1.35.0(@amplitude/rrweb@2.1.1) '@base-ui/react': specifier: 'catalog:' version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1242,7 +1242,7 @@ importers: version: 0.47.0(@typescript/typescript6@6.0.2) '@mediabunny/mp3-encoder': specifier: 'catalog:' - version: 1.55.2(mediabunny@1.55.2) + version: 1.55.5(mediabunny@1.55.5) '@monaco-editor/react': specifier: 'catalog:' version: 4.7.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1257,13 +1257,13 @@ importers: version: 1.15.0 '@orpc/tanstack-query': specifier: 'catalog:' - version: 1.15.0(@orpc/client@1.15.0)(@tanstack/query-core@5.102.2) + version: 1.15.0(@orpc/client@1.15.0)(@tanstack/query-core@5.102.8) '@remixicon/react': specifier: 'catalog:' version: 4.9.0(react@19.2.8) '@sentry/react': specifier: 'catalog:' - version: 10.70.0(react@19.2.8) + version: 10.73.0(react@19.2.8) '@streamdown/math': specifier: 'catalog:' version: 1.0.2(react@19.2.8)(supports-color@11.0.0) @@ -1272,10 +1272,10 @@ importers: version: 3.2.8 '@t3-oss/env-nextjs': specifier: 'catalog:' - version: 0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.4.3) + version: 0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.5.4) '@tanstack/query-core': specifier: 'catalog:' - version: 5.102.2 + version: 5.102.8 '@tanstack/react-form': specifier: 'catalog:' version: 1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1284,7 +1284,7 @@ importers: version: 0.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-query': specifier: 'catalog:' - version: 5.102.2(react@19.2.8) + version: 5.102.8(react@19.2.8) '@tanstack/react-virtual': specifier: 'catalog:' version: 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1335,13 +1335,13 @@ importers: version: 5.6.0 es-toolkit: specifier: 'catalog:' - version: 1.51.0 + version: 1.52.0 fast-deep-equal: specifier: 'catalog:' version: 3.1.3 foxact: specifier: 'catalog:' - version: 0.3.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) fuse.js: specifier: 'catalog:' version: 7.5.0 @@ -1365,19 +1365,19 @@ importers: version: 11.1.18 jotai: specifier: 'catalog:' - version: 2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + version: 2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) jotai-scope: specifier: 'catalog:' - version: 0.11.0(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + version: 0.11.0(jotai@2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) jotai-tanstack-query: specifier: 'catalog:' - version: 0.11.0(@tanstack/query-core@5.102.2)(@tanstack/react-query@5.102.2(react@19.2.8))(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + version: 0.11.0(@tanstack/query-core@5.102.8)(@tanstack/react-query@5.102.8(react@19.2.8))(jotai@2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) js-cookie: specifier: 'catalog:' version: 3.0.8 js-yaml: specifier: 'catalog:' - version: 5.3.0 + version: 5.4.1 jsonschema: specifier: 'catalog:' version: 1.5.0 @@ -1386,19 +1386,19 @@ importers: version: 0.17.0 ky: specifier: 'catalog:' - version: 2.0.2 + version: 2.1.0 lexical: specifier: 'catalog:' version: 0.47.0(@typescript/typescript6@6.0.2) loro-crdt: specifier: 'catalog:' - version: 1.14.1 + version: 1.15.1 mediabunny: specifier: 'catalog:' - version: 1.55.2 + version: 1.55.5 mermaid: specifier: 'catalog:' - version: 11.17.0 + version: 11.17.2 mime: specifier: 'catalog:' version: 4.1.0 @@ -1413,13 +1413,13 @@ importers: version: 1.1.0 next: specifier: 'catalog:' - version: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) nuqs: specifier: 'catalog:' - version: 2.10.0(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 2.10.1(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) nuqs-jotai: specifier: workspace:* version: link:../packages/nuqs-jotai @@ -1431,7 +1431,7 @@ importers: version: 4.2.0(react@19.2.8) qs: specifier: 'catalog:' - version: 6.15.3 + version: 6.16.0 react: specifier: 'catalog:' version: 19.2.8 @@ -1473,7 +1473,7 @@ importers: version: 0.0.1 shiki: specifier: 'catalog:' - version: 4.4.2 + version: 4.4.3 socket.io-client: specifier: 'catalog:' version: 4.8.3(supports-color@11.0.0) @@ -1485,13 +1485,13 @@ importers: version: 1.0.8 streamdown: specifier: 'catalog:' - version: 2.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@11.0.0) + version: 2.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@11.0.0) string-ts: specifier: 'catalog:' version: 2.3.1 tldts: specifier: 'catalog:' - version: 7.4.10 + version: 7.4.11 unist-util-visit: specifier: 'catalog:' version: 5.1.0 @@ -1503,7 +1503,7 @@ importers: version: 14.0.2 zod: specifier: 'catalog:' - version: 4.4.3 + version: 4.5.4 zundo: specifier: 'catalog:' version: 2.3.0(zustand@5.0.15(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) @@ -1513,7 +1513,7 @@ importers: devDependencies: '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.3.0(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 5.3.0(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@dify/contracts': specifier: workspace:* version: link:../packages/contracts @@ -1546,28 +1546,28 @@ importers: version: 4.2.3 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-links': specifier: 'catalog:' - version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-onboarding': specifier: 'catalog:' - version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) + version: 10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) '@storybook/react': specifier: 'catalog:' - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) '@tailwindcss/postcss': specifier: 'catalog:' version: 4.3.3 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@testing-library/dom': specifier: 'catalog:' version: 10.4.1 @@ -1576,7 +1576,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: 'catalog:' - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/user-event': specifier: 'catalog:' version: 14.6.6(@testing-library/dom@10.4.1) @@ -1615,13 +1615,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 6.1.1(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@vitest/browser-playwright': specifier: 'catalog:' - version: 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + version: 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) @@ -1633,7 +1633,7 @@ importers: version: 1.6.6(supports-color@11.0.0) happy-dom: specifier: 'catalog:' - version: 20.11.6 + version: 20.12.0 playwright: specifier: 'catalog:' version: 1.62.1 @@ -1645,13 +1645,13 @@ importers: version: 19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) storybook: specifier: 'catalog:' - version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) tailwindcss: specifier: 'catalog:' version: 4.3.3 tsx: specifier: 'catalog:' - version: 4.23.12 + version: 4.23.13 typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' @@ -1660,19 +1660,19 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 1.0.0-beta.8(@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 1.0.0-beta.8(@vitejs/plugin-react@6.1.1(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) vite: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plugin-inspect: specifier: 'catalog:' - version: 12.0.2(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(srvx@0.12.5) + version: 12.0.2(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(srvx@0.12.5) vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + version: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) vitest-browser-react: specifier: 'catalog:' version: 2.2.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11) @@ -1689,50 +1689,47 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@amplitude/analytics-browser@2.45.6': - resolution: {integrity: sha512-sq03ROnttLP6i3I8LXGkAqtZAoi2Y7k5Mhx3PLYiB0mwTPCcbYaW4CyR0V52NI+7BFcpsD8DxF2ZeCoxBdWGqQ==} - - '@amplitude/analytics-client-common@2.4.60': - resolution: {integrity: sha512-YdvEsxkqPk4rkw3zdJGP92wE+CQwjqDU0hI3eX/GABqivZ8HZ8VBmot7WyZPOSzGSr+FxuwwC3ZpVTY5K8OEjQ==} + '@amplitude/analytics-browser@2.45.8': + resolution: {integrity: sha512-BdRLCcKGwQq1+1x2QOdHu5PJDMkIr+fvbbcQwO6lCsTCG44PzpL38n5jW9JNDhP88nHoz+lKyNkLDN3yd98pJA==} '@amplitude/analytics-connector@1.6.4': resolution: {integrity: sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==} - '@amplitude/analytics-core@2.54.2': - resolution: {integrity: sha512-kXFatnKxQMABvr9D43BV0C6TI4gAdksXS925e9dvAbu76QwLQhWYttJHCXMdC4jYn7+QdshhCNFp6677mQNSLA==} + '@amplitude/analytics-core@2.55.0': + resolution: {integrity: sha512-fzjXEWX2o5I58Tl5jYaw2G5Tp5ejfcDkayWMogEThCl/fd6AqRKkrl/dVVqGV3h/GS3HZws7J4baIWkaq92jkQ==} '@amplitude/analytics-types@2.11.1': resolution: {integrity: sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==} - '@amplitude/element-selector@0.2.1': - resolution: {integrity: sha512-QZYvhO2cyaw6B7gFWu95QrE/l2a5oSCHgYR2S6DqZ6/NVDWIzbBtEjwLcxZFPXbO+DM1Rng6GUNljMNFtdlFDw==} + '@amplitude/element-selector@0.3.0': + resolution: {integrity: sha512-rfjXGjXqQiitxse3ZcoPLny2Uy8AXYnWAqB6oqZj39zYVuieoxhfWT50HZks3F4Ny4Zyf45tJqyLCnfvGGMs4A==} '@amplitude/experiment-core@0.7.2': resolution: {integrity: sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==} - '@amplitude/plugin-autocapture-browser@1.28.11': - resolution: {integrity: sha512-hmdzNrWgFt0y35RHUxohJx2jgaN7m/bJUvSWv4hIz4LZ8yRA0cPfPnWhdF58HaTnpk7GjplwOvxDcjO1BBHCRQ==} + '@amplitude/plugin-autocapture-browser@1.29.0': + resolution: {integrity: sha512-S+9qjMV0jGu7KoYjPeLoeGavKjdjyNqx2gMLnJQQMTi1HaaOZZFT0Y36TyRfbYuqVL3osSe3aUnoejmuDTx14w==} - '@amplitude/plugin-custom-enrichment-browser@0.1.21': - resolution: {integrity: sha512-B5JxPtA8/Bt6Ypdx9p44Y/yyDQOINyQk0MbEeiN/OGU4kkbgiPgSMNe7X4QxDlXWtViYOwGn25RrIQyXnajjwA==} + '@amplitude/plugin-custom-enrichment-browser@0.1.22': + resolution: {integrity: sha512-KDTVCOmivrLzp1Q9EJuU61Yzw0Mip9DU/VnMnfROD37rCv0QX78O7RuYVzN+R6sprgUw+tkmS512PmkbVLOFsA==} - '@amplitude/plugin-event-property-attribution-browser@0.2.13': - resolution: {integrity: sha512-7T8Ci3p8o/9UVhyeHXhWQossPVTgou8KwFAD8ntVKUkN9PvZD6I2YJgFWqtmygJfGUGpgv34OcGyAgbofsFiAQ==} + '@amplitude/plugin-event-property-attribution-browser@0.2.14': + resolution: {integrity: sha512-uFuKp4sADFMLgh6vM/+c8RC6AXpMwcYVD9ZKkTjQn6tTz20cct1AFEjnnFc7/n9oT0D8EKvUUE7SsLg+V55QvQ==} - '@amplitude/plugin-network-capture-browser@1.10.13': - resolution: {integrity: sha512-X6KynnE6wxL3dT1A23T5FQzHpKAbR0vRx69yGi3CHFSL1IVZNbQR4MMh7dVg6Ev+JGnJs7tCn+EZ8a5mYD4lIg==} + '@amplitude/plugin-network-capture-browser@1.10.14': + resolution: {integrity: sha512-MGRgT89KaPDvrZXMp+5rWgF5GTC7YkCj/gIgHZ22FgH1CrejwHhA5FWTurW28f48rOgZ+V1Ruqab70/sst4KJQ==} - '@amplitude/plugin-page-url-enrichment-browser@0.7.23': - resolution: {integrity: sha512-c3ISLxTeYlghnyH4axNZh4Jex30QwetYEL5J7kubs3S9MYqwVCFF5RoHTFwhm3WrTjOYJk2W6qWindjXbrS85A==} + '@amplitude/plugin-page-url-enrichment-browser@0.7.24': + resolution: {integrity: sha512-kYwEBh+ssdbdqslGHVV3/m0ao139HWjd4SOwBZ47EQjeYXRDzNAw752iQIr1vx/+zRRdEOLQHO3GS1JGdWSIKw==} - '@amplitude/plugin-page-view-tracking-browser@2.11.13': - resolution: {integrity: sha512-cv+crjWw51mjX0DJ4QRNGZruZC91Lhn5MSdc9XaO6h8YqDokWMbg3wpLzcU2ds0d5l3iep9qfjsx6vzcuBd1uw==} + '@amplitude/plugin-page-view-tracking-browser@2.11.14': + resolution: {integrity: sha512-FgdBz7o0XOwfgXFppIj3O+ENeGYhmkQW6LUOEcKQwR1/kKgT+2GhOLD/u9rWnGXYcjYrWH8jtmr7D62OvlF6Dg==} - '@amplitude/plugin-session-replay-browser@1.33.8': - resolution: {integrity: sha512-M91dPju4qEAPpKd0qN22cH8/QbbKlqdFOtvGZGFIBTozEv+8/bekgfpyxBlTdQaR5+NKx/eUtoneM6xIhUWdfA==} + '@amplitude/plugin-session-replay-browser@1.35.0': + resolution: {integrity: sha512-MZJkDaykiCye9QBWqm+fVf5LO+jMxzL1iovdtYxiKe1LTnbcZ8zBFcy0vpWmSfV688bmEfYrmtgzGJQK5m8M6g==} - '@amplitude/plugin-web-vitals-browser@1.1.45': - resolution: {integrity: sha512-Qsi8gpdfgXalHmio/KplZnSjYPkGbvwQUK9rcCW3vGpZo1+jzFSQVlnxVW/WmZ8ncQAtvhDFhd2xTeqLpAfVjQ==} + '@amplitude/plugin-web-vitals-browser@1.1.46': + resolution: {integrity: sha512-Pko9gf7yDNmiEhGq2aJmJmSFckN155AYJWn/3t8NPQXakzWLagFjmOuxvPx4OefIJtyROU1rYmGM/hwTrBSvBQ==} '@amplitude/rrdom@2.1.0': resolution: {integrity: sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==} @@ -1760,8 +1757,8 @@ packages: '@amplitude/rrweb@2.1.1': resolution: {integrity: sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==} - '@amplitude/session-replay-browser@1.48.2': - resolution: {integrity: sha512-N8NRiUlaEXwNhC25guHDCwhtLCZB5G21gZR7qsXJOtwMIebyd4GeM45+xRxo+77rIFIiiFA/Xx59mEXqGrSNrg==} + '@amplitude/session-replay-browser@1.50.0': + resolution: {integrity: sha512-Cp6jcdKNYyIQ6FrJQ2htLd/0SOarpOTvCFMxK799af2i/Q3/VCcgMInXtSLDO3QRHtfM1b+hLmAx3mPqDDSYcA==} '@amplitude/targeting@0.3.10': resolution: {integrity: sha512-z1Vl10M8qHRPs51/cNBWc8HEo1QrAZZtfKzI9Uuim3hCAE/XlPPZzhIwDfZE1qlgFIXLNq1MeNKJTgdDh7A2hg==} @@ -2009,6 +2006,9 @@ packages: '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -2402,160 +2402,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -2794,8 +2794,8 @@ packages: '@types/react': '>=16' react: '>=16' - '@mediabunny/mp3-encoder@1.55.2': - resolution: {integrity: sha512-T/rNwW/90eF2gaHWr168Z+J030HWDk2DlKB17316v2qbBVfB/EKfD7u6NMvDTFe5TXmK4imAenLPb9S6S4Dr+w==} + '@mediabunny/mp3-encoder@1.55.5': + resolution: {integrity: sha512-WNdWY40KXy6P23iXboIJ8IuLaUsukEF3m+kQ4jCXnDwHTOlyRd+hyq4trTDq7g9kVwB+rpj5ZSzsBq8LKsRYwQ==} peerDependencies: mediabunny: ^1.0.0 @@ -2905,57 +2905,57 @@ packages: '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3026,8 +3026,8 @@ packages: cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm-eabi@0.143.0': - resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] @@ -3038,8 +3038,8 @@ packages: cpu: [arm64] os: [android] - '@oxc-parser/binding-android-arm64@0.143.0': - resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -3050,8 +3050,8 @@ packages: cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-arm64@0.143.0': - resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -3062,8 +3062,8 @@ packages: cpu: [x64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.143.0': - resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -3074,8 +3074,8 @@ packages: cpu: [x64] os: [freebsd] - '@oxc-parser/binding-freebsd-x64@0.143.0': - resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -3086,8 +3086,8 @@ packages: cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': - resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3098,8 +3098,8 @@ packages: cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': - resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3111,8 +3111,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-gnu@0.143.0': - resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3125,8 +3125,8 @@ packages: os: [linux] libc: [musl] - '@oxc-parser/binding-linux-arm64-musl@0.143.0': - resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3139,8 +3139,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': - resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -3153,8 +3153,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': - resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] @@ -3167,8 +3167,8 @@ packages: os: [linux] libc: [musl] - '@oxc-parser/binding-linux-riscv64-musl@0.143.0': - resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] @@ -3181,8 +3181,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-s390x-gnu@0.143.0': - resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -3195,8 +3195,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.143.0': - resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3209,8 +3209,8 @@ packages: os: [linux] libc: [musl] - '@oxc-parser/binding-linux-x64-musl@0.143.0': - resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3222,8 +3222,8 @@ packages: cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-openharmony-arm64@0.143.0': - resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3239,8 +3239,8 @@ packages: cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-arm64-msvc@0.143.0': - resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3251,8 +3251,8 @@ packages: cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.143.0': - resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] @@ -3263,8 +3263,8 @@ packages: cpu: [x64] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.143.0': - resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3276,12 +3276,12 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.143.0': - resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} - '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxc-resolver/binding-android-arm-eabi@11.21.2': resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==} cpu: [arm] @@ -3837,71 +3837,71 @@ packages: resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.5 + rollup: 4.63.1 peerDependenciesMeta: rollup: optional: true - '@sentry/browser-utils@10.70.0': - resolution: {integrity: sha512-IvjhafF5NFXrPCg5EHbAPGDwIhHxgUcLlHTklZ3DAdA6ky88BJYMfevbcnOEmgavf4nylhCaquRUFNB5+szj+A==} + '@sentry/browser-utils@10.73.0': + resolution: {integrity: sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==} engines: {node: '>=18'} - '@sentry/browser@10.70.0': - resolution: {integrity: sha512-IK6+J+8H06tZe+A8L37TT5ZxxwNtyQatW8zl5RYYJ/e9CsjrM8fPi8I1OT7uquTw8UtjqFHt7bEef/Vy63ksPg==} + '@sentry/browser@10.73.0': + resolution: {integrity: sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==} engines: {node: '>=18'} '@sentry/conventions@0.16.0': resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} engines: {node: '>=14'} - '@sentry/core@10.70.0': - resolution: {integrity: sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA==} + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} engines: {node: '>=18'} - '@sentry/feedback@10.70.0': - resolution: {integrity: sha512-6VQn2ETJjHkk4QQDdx/587/JDfXsk3yBTLZ3UZOMtBVrkmwgk+1FJZ8ULJ3ud1xZcuh2icunIkc7tGVv2axdnw==} + '@sentry/feedback@10.73.0': + resolution: {integrity: sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==} engines: {node: '>=18'} - '@sentry/react@10.70.0': - resolution: {integrity: sha512-j1d/4hvoaUVKs5GRrfmwUCkiEmkfaeHsk+xgausZlzg5YvmwpF+gyevBLNP26GFEH7nyHXW4l8dbbo0eNieJmw==} + '@sentry/react@10.73.0': + resolution: {integrity: sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/replay-canvas@10.70.0': - resolution: {integrity: sha512-irzpw22bK5CF3jbecDa0gBUcfjv7tgeUoLAvtIfeHOP5ajmf3o4Cp99a9RQqkfgvkcMeRZBUwE91SQhp6Ank+w==} + '@sentry/replay-canvas@10.73.0': + resolution: {integrity: sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==} engines: {node: '>=18'} - '@sentry/replay@10.70.0': - resolution: {integrity: sha512-xMnSGzJn9Xd29rYd32lkx/gFW+5mtgqADJ2FiZvis0MBGZuDlNRwPn0/Cs1xA3JNXKV3NGfhdmmvs90w4dHSmw==} + '@sentry/replay@10.73.0': + resolution: {integrity: sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==} engines: {node: '>=18'} - '@shikijs/core@4.4.2': - resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==} + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.4.2': - resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==} + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.4.2': - resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==} + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} - '@shikijs/langs@4.4.2': - resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==} + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} - '@shikijs/primitive@4.4.2': - resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==} + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} engines: {node: '>=20'} - '@shikijs/themes@4.4.2': - resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==} + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} - '@shikijs/types@4.4.2': - resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==} + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': @@ -3986,7 +3986,7 @@ packages: resolution: {integrity: sha512-TaCLBrqVEr767+w58QDotDUiCTuE5cyRJuRcDlsKQyUIyGBv+lYD3lu8wBiVCYxgIjB/gu9HmqiC+0yx1rHzaw==} peerDependencies: esbuild: ^0.28.2 - rollup: 4.62.5 + rollup: 4.63.1 storybook: ^10.5.10 vite: '*' webpack: '*' @@ -4217,8 +4217,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@tanstack/eslint-plugin-query@5.102.2': - resolution: {integrity: sha512-0uRcRXvrZN3JLtGDMXU8Qp/or/fp1VhDZEVcD8WrUc7CF0uWbJbloJ88dfFDhiocYP31wXX4Buar/WC31fS5Ug==} + '@tanstack/eslint-plugin-query@5.102.8': + resolution: {integrity: sha512-zRjG2PL3zvoqnsSNJFyfX5Mo+OwRwTbLERwZVO3oNQQn82V949INwaLrprzsOW/ivuow7gASeAUUC24xH7kwSQ==} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ^5.6.0 || ^6.0.0 || ^7.0.0 @@ -4237,8 +4237,8 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.102.2': - resolution: {integrity: sha512-zQ5794PXBlV5Wl7N23SR1/Ss+wPE/h2Ye4XlKpm3omN8i8b/Fd4DAdqpLS2AXj5M/RMObt3k5qy3E7kzt/AvBA==} + '@tanstack/query-core@5.102.8': + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} '@tanstack/react-form@1.33.5': resolution: {integrity: sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==} @@ -4256,8 +4256,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query@5.102.2': - resolution: {integrity: sha512-KxU8ZyOEuJ81eTSgXa8GQbk/jO/rz0elYtNKt3VMtM2pRjeO8ADIu7sqqmEjFKJKqco9h2N1ojIDuHs6VfDItQ==} + '@tanstack/react-query@5.102.8': + resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} peerDependencies: react: ^18 || ^19 @@ -4291,8 +4291,8 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} engines: {node: '>=18'} peerDependencies: '@testing-library/dom': ^10.0.0 @@ -4518,8 +4518,8 @@ packages: '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -4565,55 +4565,55 @@ packages: '@types/zen-observable@0.8.3': resolution: {integrity: sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==} - '@typescript-eslint/parser@8.67.0': - resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + '@typescript-eslint/parser@8.69.0': + resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.67.0': - resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + '@typescript-eslint/project-service@8.69.0': + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.67.0': - resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + '@typescript-eslint/scope-manager@8.69.0': + resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.67.0': - resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.67.0': - resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + '@typescript-eslint/type-utils@8.69.0': + resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.67.0': - resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + '@typescript-eslint/types@8.69.0': + resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.67.0': - resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + '@typescript-eslint/typescript-estree@8.69.0': + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.67.0': - resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + '@typescript-eslint/utils@8.69.0': + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.67.0': - resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + '@typescript-eslint/visitor-keys@8.69.0': + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -4777,8 +4777,8 @@ packages: peerDependencies: vite: '*' - '@vitejs/plugin-react@6.1.0': - resolution: {integrity: sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==} + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 @@ -5259,6 +5259,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + birecord@0.1.2: resolution: {integrity: sha512-5PAPTTmMpMEb+GuMb5DebfBkipRGyIW9+gtwEBSoDA9xkhHILm04+hZQ702pMksu3d8YAuGkmgTzQWcKqTPScA==} @@ -5284,6 +5289,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -5347,6 +5357,9 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + canvas@3.2.3: resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} engines: {node: ^18.12.0 || >= 20.9.0} @@ -5496,6 +5509,10 @@ packages: resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} engines: {node: '>= 12.0.0'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -5524,8 +5541,9 @@ packages: copy-to-clipboard@4.0.2: resolution: {integrity: sha512-gklSft7IuhriZKHKpuoA1fpJSLPNgvUMWMo5BlnzAJm0zNKnznoSv23IjtNqclx8eKi6ZcdvFFzYEER/+U1LoQ==} - core-js-compat@3.49.0: - resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -5792,6 +5810,10 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -5882,6 +5904,9 @@ packages: electron-to-chromium@1.5.380: resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==} + electron-to-chromium@1.5.417: + resolution: {integrity: sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==} + elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} @@ -5975,6 +6000,9 @@ packages: es-toolkit@1.51.0: resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -6097,8 +6125,8 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 - eslint-plugin-perfectionist@5.10.1: - resolution: {integrity: sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==} + eslint-plugin-perfectionist@5.11.0: + resolution: {integrity: sha512-kZV1otBcu4xT5R1p+0x1N1wRv4pS+OIbxrA47n5a1fTnN3MWbexOx5eQgbQhGyCNbpvF/rqrbnpkGjumHF+Y0Q==} engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 @@ -6191,8 +6219,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.9.0: - resolution: {integrity: sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==} + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -6290,7 +6318,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 peerDependenciesMeta: picomatch: optional: true @@ -6322,20 +6350,20 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - formatly@0.3.0: - resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} engines: {node: '>=18.3.0'} hasBin: true - foxact@0.3.9: - resolution: {integrity: sha512-uxOL+WzfsUAqkhBR44+om67yslYTHM9ymBsIZpr4EbPEV13oDL1XtFJQOUInci2nHmqBd3aolSC+NthQIsFbUQ==} + foxact@0.3.10: + resolution: {integrity: sha512-5pyt7M2ngc9PyGX5soP3q/L6Z+JkNPEeGTyc5t200szvEiGofWy41bxNGUE/jpuhIDByLLyuumbc712HCdJWTg==} peerDependencies: react: '*' react-dom: '*' @@ -6399,8 +6427,8 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-tsconfig@4.14.1: - resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} giget@3.3.0: resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} @@ -6432,8 +6460,8 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} - globals@17.7.0: - resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} globrex@0.1.2: @@ -6455,8 +6483,8 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - happy-dom@20.11.6: - resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==} + happy-dom@20.12.0: + resolution: {integrity: sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==} engines: {node: '>=20.0.0'} has-ansi@6.0.2: @@ -6513,8 +6541,8 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} - hono@4.13.3: - resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} hosted-git-info@9.0.3: @@ -6574,8 +6602,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} engines: {node: '>= 4'} image-size@2.0.2: @@ -6742,8 +6770,8 @@ packages: react: optional: true - jotai@2.20.2: - resolution: {integrity: sha512-aHB4CNb9qRcyf0mwSB6EO5bCGAjx8cTwFgOFCE2leOnTzqACbnSWG8XoWB3LxCT1Qoj03I1OWAHszDmN4uHb/w==} + jotai@2.20.3: + resolution: {integrity: sha512-N8L9FUbeGDP+0qNJw1FebOxt+5hk+jTOwUA2LlRE4C081jUcY6F1T/FSETp4DT2/INMtiSRZyNm2D+yZdzrSgA==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': ^7.29.1 @@ -6779,14 +6807,18 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true - js-yaml@5.3.0: - resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==} + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true jsdoc-type-pratt-parser@7.2.0: resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} engines: {node: '>=20.0.0'} + jsdoc-type-pratt-parser@7.3.0: + resolution: {integrity: sha512-DoyJXo7x/n48M3NsGOs9QnEws0ft0tV3YsSgvWMNxz2hZtz+Q6fpqUD96lVYX4a+jcEkzHeFNDJOn68TzXfbdA==} + engines: {node: '>=20.0.0'} + jsdoc-type-pratt-parser@8.0.0: resolution: {integrity: sha512-uQu/fXVqVaMg6gM8/E5G5+eygVcZ1NV0Z51CvqhNa2bDWxvHMl484ETr6vph4oPyC+KUcbP/w2W2pewfCiR9aQ==} engines: {node: '>=20.0.0'} @@ -6837,8 +6869,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.32.2: - resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} + knip@6.34.0: + resolution: {integrity: sha512-bbHIrnGspYwe4EBPjjx+lvkUor0F2qfKQc5BPzPI4SOAImYA+k2ueVpIcF7d/W1LEVT7XoJUPt6zELeGZhXBgA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -6848,8 +6880,8 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - ky@2.0.2: - resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==} + ky@2.1.0: + resolution: {integrity: sha512-nIKwelw+7qVqKOlX4Eht6GXfEXlDHcdIjyLWV3s119kYt5s+70ayTnYS+0fRNmtgGRRAfqdeWQbh8YizqkS0ng==} engines: {node: '>=22'} launch-ide@1.4.5: @@ -7067,8 +7099,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loro-crdt@1.14.1: - resolution: {integrity: sha512-1BQ7neoQeJdCbD8gxlLHsy8tqCLYIJGrXSZic4s3t7toIsiFfks8Z0nGWvuEWr5miZ7giJx19x5ffkBaw+g9KQ==} + loro-crdt@1.15.1: + resolution: {integrity: sha512-J3568cXG75MNotTJg2XVhlIEW8POjAZRFhK+3yXYBaQVB1LHn/Ksk3DIqoTOBUf+cgE4yVeqPbh7VUTX2QLV2g==} loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -7181,15 +7213,15 @@ packages: mdn-data@2.29.0: resolution: {integrity: sha512-pVxQFCcaYUEAH853+v7yoI/qzhxXSq1bTb9obMYGYAN1c3Hen+XDCEvr296XhstrwlSTNgOR7mCSD4JPjbJe5A==} - mediabunny@1.55.2: - resolution: {integrity: sha512-EEx4O6qYddAdCyWPMZNDwI7uc5hewNHrPAf9jLcVhIbXoPsiqNQ+D9i1pfadmGkjN2V318jSrZljkpoziYm6Lg==} + mediabunny@1.55.5: + resolution: {integrity: sha512-m0v6y8FGXiK+HKOc3AZqU+kJPLYsSKaLitmQNQIIHZKIGlTwQ34OF+X6Ul0K8iV4b03oPse10apwuoqbvmKeAA==} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.17.0: - resolution: {integrity: sha512-Jo9N377Wb4MSnHFPTbLi2SxFpsQl4eVHoxnW5U1Md9EazvgMp3s+4ohDxr81YNTgbn5Kj7HJ3yslrSJ52kwpbA==} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7390,8 +7422,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7422,7 +7454,11 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} - node@runtime:22.23.2: + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + node@runtime:24.20.0: resolution: type: variations variants: @@ -7430,9 +7466,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-d8YwMv1KuOmOCufbnXnjlmWeBK+Vpe344cqGnkJsBKU= + integrity: sha256-FLW9etQKtfRxX7Ti+WSFjYINlCX+KR09ZfmJJua4nB4= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-aix-ppc64.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-aix-ppc64.tar.gz targets: - cpu: ppc64 os: aix @@ -7440,9 +7476,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-YRMPOUwWMNIR3VCuzENT03lIDzbTrJE82F27oa7VhcY= + integrity: sha256-QOVgfl7LPbkZJyN3baLXXZZiYPx0p6nnMcG9Z92pa8g= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-darwin-arm64.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-darwin-arm64.tar.gz targets: - cpu: arm64 os: darwin @@ -7450,9 +7486,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-WOmQIsL/iTlVdsx/1NmM6iS7aAgUddX4i4Ae6HKfsCY= + integrity: sha256-nlsmRM8Qe++2rvymdrltMpa8EBOAlvAi7TeNYjPtgfQ= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-darwin-x64.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-darwin-x64.tar.gz targets: - cpu: x64 os: darwin @@ -7460,9 +7496,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-ATtZz9KBlwOm9KFKuJH8RvwqTj9bzZLeP7SSm0PjWzA= + integrity: sha256-NRVgPiSHh5o5vHVxbxoq/9AnUAxkulDoRc9yyzMhkBM= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-arm64.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-arm64.tar.gz targets: - cpu: arm64 os: linux @@ -7470,19 +7506,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-Ki9Z64/Z3sJ7O+4XxykTHR/T5tmUPUefEVbOOK+M1Zk= + integrity: sha256-pzSzbI0dFs5FXA65wymwfdEhwshVQ1UGSphhvJbDrcw= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-armv7l.tar.gz - targets: - - cpu: armv7l - os: linux - - resolution: - archive: tarball - bin: - node: bin/node - integrity: sha256-ZciqnmRxlvMNNWS7ayATBrG1IPcvDdMnjaMmAUoBg3k= - type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-ppc64le.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-ppc64le.tar.gz targets: - cpu: ppc64le os: linux @@ -7490,9 +7516,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-mGuEQbHRoBxw7XELqyAXQf0NGW0wKn5DOP79XhwQ5h4= + integrity: sha256-2MIktG7wHweKUj2bfbeSgjBvrigemGoVotv/6UfFMB4= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-s390x.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-s390x.tar.gz targets: - cpu: s390x os: linux @@ -7500,9 +7526,20 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-spSlVuY51kM4gjkg5YZsIcAnQXQtLhUp7hoiXB7JJSo= + integrity: sha256-1ubRu7mtSssW1YC4m+ipyafkkJJuk708MKINRuFSv4o= type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-x64.tar.gz + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-hV1YH4pOsagRfjQm3iX+AncFkv68+zE2mu4f+/7p6Ow= + type: binary + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64.tar.gz targets: - cpu: x64 os: linux @@ -7510,10 +7547,10 @@ packages: archive: zip bin: node: node.exe - integrity: sha256-/sAlptoxdX47avhMWhYo6dOEQsqZohYQkdePL8+jXvM= - prefix: node-v22.23.2-win-arm64 + integrity: sha256-McZ5l0TeilRgFkMJgEDGjDaX5WyU5AfWHQ5fpfNBkdc= + prefix: node-v24.20.0-win-arm64 type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-win-arm64.zip + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-win-arm64.zip targets: - cpu: arm64 os: win32 @@ -7521,31 +7558,20 @@ packages: archive: zip bin: node: node.exe - integrity: sha256-EXe0E3ulrapWNUrkDxCAx0UOiuCc7LR9pFnRxSrJn5c= - prefix: node-v22.23.2-win-x64 + integrity: sha256-bKyf+8qPakcJHktcdy4GBgScOHHLZ9kAwM7d5jDlRbo= + prefix: node-v24.20.0-win-x64 type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-win-x64.zip + url: https://nodejs.org/download/release/v24.20.0/node-v24.20.0-win-x64.zip targets: - cpu: x64 os: win32 - - resolution: - archive: zip - bin: - node: node.exe - integrity: sha256-clyeK90cIBa0HJlagfT6Ns5OLuVlt0Vdj4iRgnJ99kc= - prefix: node-v22.23.2-win-x86 - type: binary - url: https://nodejs.org/download/release/v22.23.2/node-v22.23.2-win-x86.zip - targets: - - cpu: x86 - os: win32 - resolution: archive: tarball bin: node: bin/node - integrity: sha256-t6GiscfHbkdVDxd2RnaTmEDgpktNBL3TdaSsFLzKqNg= + integrity: sha256-LIxQfMsPIIEtlSa6jKRUsWUqre9o/IutBvB/sRIt0e8= type: binary - url: https://unofficial-builds.nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-arm64-musl.tar.gz + url: https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-arm64-musl.tar.gz targets: - cpu: arm64 os: linux @@ -7554,14 +7580,14 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-OW4R7mCesuXLmQ8EXE0DeqR7LCR/POywHFwWLjP/qa8= + integrity: sha256-muE5n+9L2JkOFXc84TJ7M2oguel9jHVJ9PQspzxD9WI= type: binary - url: https://unofficial-builds.nodejs.org/download/release/v22.23.2/node-v22.23.2-linux-x64-musl.tar.gz + url: https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.gz targets: - cpu: x64 os: linux libc: musl - version: 22.23.2 + version: 24.20.0 hasBin: true normalize-package-data@8.0.0: @@ -7577,8 +7603,8 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nuqs@2.10.0: - resolution: {integrity: sha512-uy62jWCXiU1mcGfoClUFNTfrbaxfPQ8WuiomYlkXw5llVdN8kRZVYxOQ2LD70k3DzVQz5bZczE9ClVXwOC22Dw==} + nuqs@2.10.1: + resolution: {integrity: sha512-7lPZrPJVOsD0VvfQSKtodYBBPGPLsLzkFU8ueYIap9yMMJvSw6UC12p8luA5NW7aKe7AbPHKxcxb3OluH6iAJA==} peerDependencies: '@remix-run/react': '>=2' '@tanstack/react-router': ^1 @@ -7637,6 +7663,10 @@ packages: resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} engines: {node: '>=20'} + open@11.0.2: + resolution: {integrity: sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==} + engines: {node: '>=20'} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -7652,8 +7682,8 @@ packages: resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-parser@0.143.0: - resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.21.2: @@ -7711,6 +7741,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pad-right@0.2.2: resolution: {integrity: sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==} engines: {node: '>=0.10.0'} @@ -7802,6 +7835,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pinyin-pro@3.29.3: resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==} @@ -7861,6 +7898,10 @@ packages: resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} engines: {node: '>=20'} + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -7896,8 +7937,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -8111,8 +8152,8 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - remend@1.3.0: - resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + remend@1.3.1: + resolution: {integrity: sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==} repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} @@ -8211,8 +8252,8 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -8232,8 +8273,8 @@ packages: resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} - shiki@4.4.2: - resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==} + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} siginfo@2.0.0: @@ -8259,8 +8300,8 @@ packages: size-sensor@1.0.3: resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} - smol-toml@1.7.1: - resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} socket.io-client@4.8.3: @@ -8348,8 +8389,8 @@ packages: vite-plus: optional: true - streamdown@2.5.0: - resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} + streamdown@2.6.0: + resolution: {integrity: sha512-nQZVUn4GvB2R5SAlDNph20iKZeW+RM4gv6G1H3ACypEnmQf8PgTHZ/1Ta2OACRwQ2coK7aW5AeIAa7Ls5zk+RA==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -8535,11 +8576,11 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.4.10: - resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} - tldts@7.4.10: - resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} hasBin: true to-regex-range@5.0.1: @@ -8615,8 +8656,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} hasBin: true @@ -8726,6 +8767,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -9086,6 +9133,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zrender@6.1.0: resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} @@ -9136,28 +9186,21 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amplitude/analytics-browser@2.45.6': + '@amplitude/analytics-browser@2.45.8': dependencies: - '@amplitude/analytics-core': 2.54.2 - '@amplitude/plugin-autocapture-browser': 1.28.11 - '@amplitude/plugin-custom-enrichment-browser': 0.1.21 - '@amplitude/plugin-event-property-attribution-browser': 0.2.13 - '@amplitude/plugin-network-capture-browser': 1.10.13 - '@amplitude/plugin-page-url-enrichment-browser': 0.7.23 - '@amplitude/plugin-page-view-tracking-browser': 2.11.13 - '@amplitude/plugin-web-vitals-browser': 1.1.45 - tslib: 2.8.1 - - '@amplitude/analytics-client-common@2.4.60': - dependencies: - '@amplitude/analytics-connector': 1.6.4 - '@amplitude/analytics-core': 2.54.2 - '@amplitude/analytics-types': 2.11.1 + '@amplitude/analytics-core': 2.55.0 + '@amplitude/plugin-autocapture-browser': 1.29.0 + '@amplitude/plugin-custom-enrichment-browser': 0.1.22 + '@amplitude/plugin-event-property-attribution-browser': 0.2.14 + '@amplitude/plugin-network-capture-browser': 1.10.14 + '@amplitude/plugin-page-url-enrichment-browser': 0.7.24 + '@amplitude/plugin-page-view-tracking-browser': 2.11.14 + '@amplitude/plugin-web-vitals-browser': 1.1.46 tslib: 2.8.1 '@amplitude/analytics-connector@1.6.4': {} - '@amplitude/analytics-core@2.54.2': + '@amplitude/analytics-core@2.55.0': dependencies: '@amplitude/analytics-connector': 1.6.4 '@types/zen-observable': 0.8.3 @@ -9167,7 +9210,7 @@ snapshots: '@amplitude/analytics-types@2.11.1': {} - '@amplitude/element-selector@0.2.1': + '@amplitude/element-selector@0.3.0': dependencies: tslib: 2.8.1 @@ -9175,52 +9218,51 @@ snapshots: dependencies: js-base64: 3.8.0 - '@amplitude/plugin-autocapture-browser@1.28.11': + '@amplitude/plugin-autocapture-browser@1.29.0': dependencies: - '@amplitude/analytics-core': 2.54.2 - '@amplitude/element-selector': 0.2.1 + '@amplitude/analytics-core': 2.55.0 + '@amplitude/element-selector': 0.3.0 tslib: 2.8.1 - '@amplitude/plugin-custom-enrichment-browser@0.1.21': + '@amplitude/plugin-custom-enrichment-browser@0.1.22': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 tslib: 2.8.1 - '@amplitude/plugin-event-property-attribution-browser@0.2.13': + '@amplitude/plugin-event-property-attribution-browser@0.2.14': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 tslib: 2.8.1 - '@amplitude/plugin-network-capture-browser@1.10.13': + '@amplitude/plugin-network-capture-browser@1.10.14': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 tslib: 2.8.1 - '@amplitude/plugin-page-url-enrichment-browser@0.7.23': + '@amplitude/plugin-page-url-enrichment-browser@0.7.24': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 tslib: 2.8.1 - '@amplitude/plugin-page-view-tracking-browser@2.11.13': + '@amplitude/plugin-page-view-tracking-browser@2.11.14': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 tslib: 2.8.1 - '@amplitude/plugin-session-replay-browser@1.33.8(@amplitude/rrweb@2.1.1)': + '@amplitude/plugin-session-replay-browser@1.35.0(@amplitude/rrweb@2.1.1)': dependencies: - '@amplitude/analytics-client-common': 2.4.60 - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/rrweb-plugin-console-record': 2.0.0-alpha.40(@amplitude/rrweb@2.1.1) '@amplitude/rrweb-record': 2.0.0-alpha.40 - '@amplitude/session-replay-browser': 1.48.2(@amplitude/rrweb@2.1.1) + '@amplitude/session-replay-browser': 1.50.0(@amplitude/rrweb@2.1.1) tslib: 2.8.1 transitivePeerDependencies: - '@amplitude/rrweb' - '@amplitude/plugin-web-vitals-browser@1.1.45': + '@amplitude/plugin-web-vitals-browser@1.1.46': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 tslib: 2.8.1 web-vitals: 5.1.0 @@ -9258,9 +9300,9 @@ snapshots: base64-arraybuffer: 1.0.2 mitt: 3.0.1 - '@amplitude/session-replay-browser@1.48.2(@amplitude/rrweb@2.1.1)': + '@amplitude/session-replay-browser@1.50.0(@amplitude/rrweb@2.1.1)': dependencies: - '@amplitude/analytics-core': 2.54.2 + '@amplitude/analytics-core': 2.55.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/rrweb-plugin-console-record': 2.0.0-alpha.40(@amplitude/rrweb@2.1.1) '@amplitude/rrweb-record': 2.0.0-alpha.40 @@ -9330,7 +9372,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.4 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -9423,12 +9465,12 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@chromatic-com/storybook@5.3.0(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@chromatic-com/storybook@5.3.0(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 18.2.0 jsonfile: 6.2.1 - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -9630,6 +9672,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -9650,7 +9697,7 @@ snapshots: '@es-joy/jsdoccomment@0.88.0': dependencies: '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/types': 8.69.0 comment-parser: 1.4.7 esquery: 1.7.0 jsdoc-type-pratt-parser: 7.2.0 @@ -9658,7 +9705,7 @@ snapshots: '@es-joy/jsdoccomment@0.91.0': dependencies: '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/types': 8.69.0 comment-parser: 1.4.7 esquery: 1.7.0 jsdoc-type-pratt-parser: 8.0.0 @@ -9743,100 +9790,100 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))': + '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))': dependencies: escape-string-regexp: 4.0.0 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - ignore: 7.0.5 + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + ignore: 7.0.8 - '@eslint-community/eslint-utils@4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))': dependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/ast@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) string-ts: 2.3.1 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@eslint-react/core@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/core@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-pattern: 5.9.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@eslint-react/eslint-plugin@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/eslint-plugin@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - eslint-plugin-react-dom: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint-plugin-react-jsx: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint-plugin-react-naming-convention: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint-plugin-react-rsc: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint-plugin-react-web-api: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint-plugin-react-x: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + eslint-plugin-react-dom: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint-plugin-react-jsx: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint-plugin-react-naming-convention: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint-plugin-react-rsc: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint-plugin-react-web-api: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint-plugin-react-x: 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@eslint-react/eslint@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/eslint@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@eslint-react/jsx@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/jsx@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-pattern: 5.9.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@eslint-react/shared@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/shared@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-pattern: 5.9.0 typescript: '@typescript/typescript6@6.0.2' - zod: 4.4.3 + zod: 4.5.4 transitivePeerDependencies: - supports-color - '@eslint-react/var@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@eslint-react/var@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-pattern: 5.9.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: @@ -9994,9 +10041,9 @@ snapshots: '@hey-api/types@0.1.4': {} - '@hono/node-server@2.1.1(hono@4.13.3)': + '@hono/node-server@2.1.1(hono@4.13.5)': dependencies: - hono: 4.13.3 + hono: 4.13.5 '@humanfs/core@0.19.2': dependencies: @@ -10064,119 +10111,119 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(@typescript/typescript6@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' optionalDependencies: typescript: '@typescript/typescript6@6.0.2' @@ -10437,9 +10484,9 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@mediabunny/mp3-encoder@1.55.2(mediabunny@1.55.2)': + '@mediabunny/mp3-encoder@1.55.5(mediabunny@1.55.5)': dependencies: - mediabunny: 1.55.2 + mediabunny: 1.55.5 '@mermaid-js/parser@1.2.1': dependencies: @@ -10531,30 +10578,30 @@ snapshots: '@next/env@16.0.0': {} - '@next/env@16.3.3': {} + '@next/env@16.3.4': {} - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@nodelib/fs.scandir@2.1.5': @@ -10627,11 +10674,11 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.15.0(@orpc/client@1.15.0)(@tanstack/query-core@5.102.2)': + '@orpc/tanstack-query@1.15.0(@orpc/client@1.15.0)(@tanstack/query-core@5.102.8)': dependencies: '@orpc/client': 1.15.0 '@orpc/shared': 1.15.0 - '@tanstack/query-core': 5.102.2 + '@tanstack/query-core': 5.102.8 transitivePeerDependencies: - '@opentelemetry/api' @@ -10640,97 +10687,97 @@ snapshots: '@oxc-parser/binding-android-arm-eabi@0.127.0': optional: true - '@oxc-parser/binding-android-arm-eabi@0.143.0': + '@oxc-parser/binding-android-arm-eabi@0.147.0': optional: true '@oxc-parser/binding-android-arm64@0.127.0': optional: true - '@oxc-parser/binding-android-arm64@0.143.0': + '@oxc-parser/binding-android-arm64@0.147.0': optional: true '@oxc-parser/binding-darwin-arm64@0.127.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.143.0': + '@oxc-parser/binding-darwin-arm64@0.147.0': optional: true '@oxc-parser/binding-darwin-x64@0.127.0': optional: true - '@oxc-parser/binding-darwin-x64@0.143.0': + '@oxc-parser/binding-darwin-x64@0.147.0': optional: true '@oxc-parser/binding-freebsd-x64@0.127.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.143.0': + '@oxc-parser/binding-freebsd-x64@0.147.0': optional: true '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': optional: true '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': optional: true '@oxc-parser/binding-linux-arm64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-arm64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.143.0': + '@oxc-parser/binding-linux-arm64-musl@0.147.0': optional: true '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-riscv64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': optional: true '@oxc-parser/binding-linux-s390x-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-x64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.143.0': + '@oxc-parser/binding-linux-x64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-x64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.143.0': + '@oxc-parser/binding-linux-x64-musl@0.147.0': optional: true '@oxc-parser/binding-openharmony-arm64@0.127.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.143.0': + '@oxc-parser/binding-openharmony-arm64@0.147.0': optional: true '@oxc-parser/binding-wasm32-wasi@0.127.0': @@ -10743,29 +10790,29 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': optional: true '@oxc-parser/binding-win32-ia32-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': optional: true '@oxc-parser/binding-win32-x64-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.143.0': + '@oxc-parser/binding-win32-x64-msvc@0.147.0': optional: true '@oxc-project/runtime@0.146.0': {} '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.143.0': {} - '@oxc-project/types@0.146.0': {} + '@oxc-project/types@0.147.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.21.2': optional: true @@ -11124,83 +11171,83 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.5 + picomatch: 4.0.7 - '@sentry/browser-utils@10.70.0': + '@sentry/browser-utils@10.73.0': dependencies: '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 + '@sentry/core': 10.73.0 - '@sentry/browser@10.70.0': + '@sentry/browser@10.73.0': dependencies: - '@sentry/browser-utils': 10.70.0 + '@sentry/browser-utils': 10.73.0 '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 - '@sentry/feedback': 10.70.0 - '@sentry/replay': 10.70.0 - '@sentry/replay-canvas': 10.70.0 + '@sentry/core': 10.73.0 + '@sentry/feedback': 10.73.0 + '@sentry/replay': 10.73.0 + '@sentry/replay-canvas': 10.73.0 '@sentry/conventions@0.16.0': {} - '@sentry/core@10.70.0': + '@sentry/core@10.73.0': dependencies: '@sentry/conventions': 0.16.0 - '@sentry/feedback@10.70.0': + '@sentry/feedback@10.73.0': dependencies: - '@sentry/core': 10.70.0 + '@sentry/core': 10.73.0 - '@sentry/react@10.70.0(react@19.2.8)': + '@sentry/react@10.73.0(react@19.2.8)': dependencies: - '@sentry/browser': 10.70.0 + '@sentry/browser': 10.73.0 '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 + '@sentry/core': 10.73.0 react: 19.2.8 - '@sentry/replay-canvas@10.70.0': + '@sentry/replay-canvas@10.73.0': dependencies: - '@sentry/core': 10.70.0 - '@sentry/replay': 10.70.0 + '@sentry/core': 10.73.0 + '@sentry/replay': 10.73.0 - '@sentry/replay@10.70.0': + '@sentry/replay@10.73.0': dependencies: - '@sentry/browser-utils': 10.70.0 - '@sentry/core': 10.70.0 + '@sentry/browser-utils': 10.73.0 + '@sentry/core': 10.73.0 - '@shikijs/core@4.4.2': + '@shikijs/core@4.4.3': dependencies: - '@shikijs/primitive': 4.4.2 - '@shikijs/types': 4.4.2 + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.4.2': + '@shikijs/engine-javascript@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.4.2': + '@shikijs/engine-oniguruma@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.4.2': + '@shikijs/langs@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 - '@shikijs/primitive@4.4.2': + '@shikijs/primitive@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/themes@4.4.2': + '@shikijs/themes@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 - '@shikijs/types@4.4.2': + '@shikijs/types@4.4.3': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -11218,21 +11265,21 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-a11y@10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/addon-a11y@10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.13.0 - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@storybook/csf-plugin': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + '@storybook/csf-plugin': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) '@storybook/icons': 2.1.0(react@19.2.8) - '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) ts-dedent: 2.3.0 optionalDependencies: '@types/react': 19.2.18 @@ -11243,54 +11290,54 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.5.10(@types/react@19.2.18)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/addon-links@10.5.10(@types/react@19.2.18)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.18 react: 19.2.8 - '@storybook/addon-onboarding@10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/addon-onboarding@10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - '@storybook/addon-themes@10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/addon-themes@10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) ts-dedent: 2.3.0 - '@storybook/addon-vitest@10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(vitest@4.1.11)': + '@storybook/addon-vitest@10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(vitest@4.1.11)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) '@vitest/runner': 4.1.11 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) transitivePeerDependencies: - react - '@storybook/builder-vite@10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/builder-vite@10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: - '@storybook/csf-plugin': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) ts-dedent: 2.3.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/csf-plugin@10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.2 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' '@storybook/global@5.0.0': {} @@ -11298,18 +11345,18 @@ snapshots: dependencies: react: 19.2.8 - '@storybook/nextjs-vite@10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)': + '@storybook/nextjs-vite@10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0)': dependencies: - '@storybook/builder-vite': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) - '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) - '@storybook/react-vite': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) - next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@storybook/builder-vite': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) + '@storybook/react-vite': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) + next: 16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@11.0.0))(react@19.2.8) - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' - vite-plugin-storybook-nextjs: 3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' + vite-plugin-storybook-nextjs: 3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) @@ -11322,30 +11369,30 @@ snapshots: - supports-color - webpack - '@storybook/react-dom-shim@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + '@storybook/react-dom-shim@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0)': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0 - '@storybook/builder-vite': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) - '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0) + '@storybook/builder-vite': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 react-docgen: 8.0.3(supports-color@11.0.0) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) tsconfig-paths: 4.2.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' optionalDependencies: typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: @@ -11356,15 +11403,15 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)': + '@storybook/react@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) react: 19.2.8 react-docgen: 8.0.3(supports-color@11.0.0) react-docgen-typescript: 2.4.0(@typescript/typescript6@6.0.2) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) @@ -11387,19 +11434,19 @@ snapshots: dependencies: tslib: 2.8.1 - '@t3-oss/env-core@0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.4.3)': + '@t3-oss/env-core@0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.5.4)': optionalDependencies: typescript: '@typescript/typescript6@6.0.2' valibot: 1.4.2(@typescript/typescript6@6.0.2) - zod: 4.4.3 + zod: 4.5.4 - '@t3-oss/env-nextjs@0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.4.3)': + '@t3-oss/env-nextjs@0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.5.4)': dependencies: - '@t3-oss/env-core': 0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.4.3) + '@t3-oss/env-core': 0.13.11(@typescript/typescript6@6.0.2)(valibot@1.4.2(@typescript/typescript6@6.0.2))(zod@4.5.4) optionalDependencies: typescript: '@typescript/typescript6@6.0.2' valibot: 1.4.2(@typescript/typescript6@6.0.2) - zod: 4.4.3 + zod: 4.5.4 '@tailwindcss/node@4.3.3': dependencies: @@ -11470,19 +11517,19 @@ snapshots: postcss: 8.5.26 tailwindcss: 4.3.3 - '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.4': {} - '@tanstack/eslint-plugin-query@5.102.2(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@tanstack/eslint-plugin-query@5.102.8(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) optionalDependencies: typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: @@ -11500,7 +11547,7 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.102.2': {} + '@tanstack/query-core@5.102.8': {} '@tanstack/react-form@1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -11517,9 +11564,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@tanstack/react-query@5.102.2(react@19.2.8)': + '@tanstack/react-query@5.102.8(react@19.2.8)': dependencies: - '@tanstack/query-core': 5.102.2 + '@tanstack/query-core': 5.102.8 react: 19.2.8 '@tanstack/react-store@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': @@ -11561,7 +11608,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 @@ -11811,7 +11858,7 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/node@26.0.1': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 @@ -11819,7 +11866,7 @@ snapshots: '@types/papaparse@5.5.2': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/qs@6.15.1': {} @@ -11845,65 +11892,65 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/yauzl@2.10.3': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 optional: true '@types/zen-observable@0.8.3': {} - '@typescript-eslint/parser@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0)': + '@typescript-eslint/project-service@8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.67.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/tsconfig-utils': 8.69.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.69.0 debug: 4.4.3(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.67.0': + '@typescript-eslint/scope-manager@8.69.0': dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 - '@typescript-eslint/tsconfig-utils@8.67.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.69.0(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@typescript-eslint/type-utils@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) debug: 4.4.3(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0)': + '@typescript-eslint/typescript-estree@8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0)': dependencies: - '@typescript-eslint/project-service': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - '@typescript-eslint/tsconfig-utils': 8.67.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/project-service': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + '@typescript-eslint/tsconfig-utils': 8.69.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3(supports-color@11.0.0) minimatch: 10.2.5 semver: 7.8.5 @@ -11913,20 +11960,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': + '@typescript-eslint/utils@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.67.0': + '@typescript-eslint/visitor-keys@8.69.0': dependencies: - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -11999,13 +12046,13 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@unpic/react@1.0.2(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@unpic/core': 1.0.3 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -12023,24 +12070,24 @@ snapshots: '@vinext/types@1.0.0-beta.2': {} - '@vitejs/devtools-kit@0.4.1(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(srvx@0.12.5)': + '@vitejs/devtools-kit@0.4.1(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(srvx@0.12.5)': dependencies: '@devframes/hub': 0.6.2(devframe@0.6.2(@typescript/typescript6@6.0.2)(srvx@0.12.5)) devframe: 0.6.2(@typescript/typescript6@6.0.2)(srvx@0.12.5) mlly: 1.8.2 nostics: 1.2.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' transitivePeerDependencies: - '@modelcontextprotocol/sdk' - srvx - typescript - '@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.1(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.3.1 @@ -12051,31 +12098,31 @@ snapshots: srvx: 0.12.5 strip-literal: 3.1.0 turbo-stream: 3.2.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) optionalDependencies: react-server-dom-webpack: 19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@vitest/browser-playwright@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11)': + '@vitest/browser-playwright@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) playwright: 1.62.1 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser-playwright@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11)': + '@vitest/browser-playwright@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)) playwright: 1.62.1 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.12.0) transitivePeerDependencies: - bufferutil - msw @@ -12083,40 +12130,40 @@ snapshots: - vite optional: true - '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11)': + '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.6(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11)': + '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.6(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.11.6) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.12.0) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11)': + '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -12124,16 +12171,16 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11)': + '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)) '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.12.0) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -12153,9 +12200,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) optionalDependencies: - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) '@vitest/expect@3.2.4': dependencies: @@ -12174,21 +12221,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' - '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)' '@vitest/pretty-format@3.2.4': dependencies: @@ -12228,7 +12275,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.146.0 '@oxc-project/types': 0.146.0 @@ -12249,11 +12296,11 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - tsx: 4.23.12 + tsx: 4.23.13 typescript: '@typescript/typescript6@6.0.2' yaml: 2.9.0 - '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.146.0 '@oxc-project/types': 0.146.0 @@ -12274,7 +12321,7 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - tsx: 4.23.12 + tsx: 4.23.13 typescript: 7.0.2 yaml: 2.9.0 @@ -12505,6 +12552,8 @@ snapshots: baseline-browser-mapping@2.10.40: {} + baseline-browser-mapping@2.11.20: {} + birecord@0.1.2: {} birpc@4.0.0: {} @@ -12534,13 +12583,21 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.417 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) + buffer-crc32@0.2.13: {} buffer-from@1.1.2: {} buffer-image-size@0.6.4: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 buffer@5.7.1: dependencies: @@ -12594,6 +12651,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001810: {} + canvas@3.2.3: dependencies: node-addon-api: 7.1.1 @@ -12734,6 +12793,8 @@ snapshots: comment-parser@1.4.7: {} + comment-parser@1.4.8: {} + compare-versions@6.1.1: {} concurrently@10.0.5: @@ -12757,9 +12818,9 @@ snapshots: copy-to-clipboard@4.0.2: {} - core-js-compat@3.49.0: + core-js-compat@3.50.0: dependencies: - browserslist: 4.28.4 + browserslist: 4.28.8 cose-base@1.0.3: dependencies: @@ -13044,6 +13105,11 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-lazy-prop@3.0.0: {} defu@6.1.7: {} @@ -13133,6 +13199,8 @@ snapshots: electron-to-chromium@1.5.380: {} + electron-to-chromium@1.5.417: {} + elkjs@0.11.1: {} embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): @@ -13213,6 +13281,8 @@ snapshots: es-toolkit@1.51.0: {} + es-toolkit@1.52.0: {} + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -13250,32 +13320,32 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-compat-utils@0.5.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) semver: 7.8.5 - eslint-json-compat-utils@0.2.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(jsonc-eslint-parser@3.3.0): + eslint-json-compat-utils@0.2.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(jsonc-eslint-parser@3.3.0): dependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) esquery: 1.7.0 jsonc-eslint-parser: 3.3.0 - eslint-markdown@0.12.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-markdown@0.12.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@eslint/markdown': 7.5.1(supports-color@11.0.0) micromark-util-normalize-identifier: 2.0.1 parse5: 8.0.1 optionalDependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - eslint-plugin-antfu@3.2.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-antfu@3.2.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) - eslint-plugin-better-tailwindcss@4.7.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(tailwindcss@4.3.3): + eslint-plugin-better-tailwindcss@4.7.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(tailwindcss@4.3.3): dependencies: '@eslint/css-tree': 4.0.5 '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(@typescript/typescript6@6.0.2)) @@ -13287,37 +13357,37 @@ snapshots: tsconfig-paths-webpack-plugin: 4.2.0 valibot: 1.4.2(@typescript/typescript6@6.0.2) optionalDependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) transitivePeerDependencies: - '@eslint/css' - typescript - eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: '@es-joy/jsdoccomment': 0.88.0 - '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) - eslint-plugin-erasable-syntax-only@0.4.2(@typescript-eslint/parser@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-erasable-syntax-only@0.4.2(@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@typescript-eslint/parser': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/parser': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) cached-factory: 0.3.0 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-es-x@7.8.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) '@eslint-community/regexpp': 4.12.2 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - eslint-compat-utils: 0.5.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + eslint-compat-utils: 0.5.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) - eslint-plugin-jsdoc@63.3.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-jsdoc@63.3.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@es-joy/jsdoccomment': 0.91.0 '@es-joy/resolve.exports': 1.2.0 @@ -13325,7 +13395,7 @@ snapshots: comment-parser: 1.4.7 debug: 4.4.3(supports-color@11.0.0) escape-string-regexp: 4.0.0 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) espree: 11.2.0 esquery: 1.7.0 html-entities: 2.6.0 @@ -13337,27 +13407,27 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-jsonc@3.4.2(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-jsonc@3.4.2(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@ota-meshi/ast-token-store': 0.3.0 diff-sequences: 29.6.3 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - eslint-json-compat-utils: 0.2.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(jsonc-eslint-parser@3.3.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + eslint-json-compat-utils: 0.2.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(jsonc-eslint-parser@3.3.0) jsonc-eslint-parser: 3.3.0 natural-compare: 1.4.0 synckit: 0.11.13 transitivePeerDependencies: - '@eslint/json' - eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@eslint/markdown': 8.0.3(supports-color@11.0.0) diff-sequences: 29.6.3 emoji-regex-xs: 2.0.1 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) mdast-util-from-markdown: 2.0.3(supports-color@11.0.0) mdast-util-frontmatter: 2.0.1(supports-color@11.0.0) mdast-util-gfm: 3.1.0(supports-color@11.0.0) @@ -13372,13 +13442,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-n@18.3.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-n@18.3.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) enhanced-resolve: 5.24.1 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - eslint-plugin-es-x: 7.8.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) - get-tsconfig: 4.14.1 + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + eslint-plugin-es-x: 7.8.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) + get-tsconfig: 4.14.3 globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 @@ -13386,28 +13456,28 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' - eslint-plugin-no-barrel-files@1.3.1(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-no-barrel-files@1.3.1(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-perfectionist@5.10.1(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-perfectionist@5.11.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-pnpm@1.8.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-pnpm@1.8.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: empathic: 2.0.1 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - eslint-json-compat-utils: 0.2.3(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(jsonc-eslint-parser@3.3.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + eslint-json-compat-utils: 0.2.3(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(jsonc-eslint-parser@3.3.0) jsonc-eslint-parser: 3.3.0 pathe: 2.0.3 pnpm-workspace-yaml: 1.8.0 @@ -13417,94 +13487,94 @@ snapshots: transitivePeerDependencies: - '@eslint/json' - eslint-plugin-react-dom@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-react-dom@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/jsx': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/jsx': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) compare-versions: 6.1.1 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - eslint-plugin-react-jsx@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-react-jsx@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/jsx': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/jsx': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-react-naming-convention@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-pattern: 5.9.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - eslint-plugin-react-rsc@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-react-rsc@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-react-web-api@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) birecord: 0.1.2 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) ts-pattern: 5.9.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - eslint-plugin-react-x@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-react-x@5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/jsx': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/type-utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/ast': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/core': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/eslint': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/jsx': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/shared': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@eslint-react/var': 5.18.6(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/type-utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) compare-versions: 6.1.1 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) string-ts: 2.3.1 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) ts-pattern: 5.9.0 @@ -13512,48 +13582,48 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-regexp@3.1.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-regexp@3.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) '@eslint-community/regexpp': 4.12.2 - comment-parser: 1.4.7 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) - jsdoc-type-pratt-parser: 7.2.0 + comment-parser: 1.4.8 + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) + jsdoc-type-pratt-parser: 7.3.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-storybook@10.5.10(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-storybook@10.5.10(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-toml@1.5.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-toml@1.5.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@ota-meshi/ast-token-store': 0.3.0 debug: 4.4.3(supports-color@11.0.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) toml-eslint-parser: 1.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@71.1.0(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-unicorn@71.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) browserslist: 4.28.4 change-case: 5.4.4 ci-info: 4.4.0 - core-js-compat: 3.49.0 + core-js-compat: 3.50.0 detect-indent: 7.0.2 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) find-up-simple: 1.0.1 - globals: 17.7.0 + globals: 17.11.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 is-identifier: 1.1.0 @@ -13564,14 +13634,14 @@ snapshots: semver: 7.8.5 strip-indent: 4.1.1 - eslint-plugin-yml@3.8.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-yml@3.8.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)): dependencies: '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@ota-meshi/ast-token-store': 0.3.0 diff-sequences: 29.6.3 escape-string-regexp: 5.0.0 - eslint: 10.9.0(jiti@2.7.0)(supports-color@11.0.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@11.0.0) natural-compare: 1.4.0 yaml-eslint-parser: 2.1.0 @@ -13586,9 +13656,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0): + eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@11.0.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@11.0.0) '@eslint/config-helpers': 0.7.0 @@ -13704,9 +13774,9 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 fflate@0.7.4: {} @@ -13731,18 +13801,19 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.4: {} format@0.2.2: {} - formatly@0.3.0: + formatly@0.7.0: dependencies: fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 - foxact@0.3.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + foxact@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: client-only: 0.0.1 event-target-bus: 1.1.0 @@ -13787,7 +13858,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-tsconfig@4.14.1: + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -13818,7 +13889,7 @@ snapshots: globals@15.15.0: {} - globals@17.7.0: {} + globals@17.11.0: {} globrex@0.1.2: {} @@ -13833,9 +13904,9 @@ snapshots: hachure-fill@0.5.2: {} - happy-dom@20.11.6: + happy-dom@20.12.0: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -13980,7 +14051,7 @@ snapshots: hex-rgb@4.3.0: {} - hono@4.13.3: {} + hono@4.13.5: {} hosted-git-info@9.0.3: dependencies: @@ -14036,7 +14107,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.8: {} image-size@2.0.2: {} @@ -14140,20 +14211,20 @@ snapshots: jiti@2.7.0: {} - jotai-scope@0.11.0(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): + jotai-scope@0.11.0(jotai@2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): dependencies: - jotai: 2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + jotai: 2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 - jotai-tanstack-query@0.11.0(@tanstack/query-core@5.102.2)(@tanstack/react-query@5.102.2(react@19.2.8))(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): + jotai-tanstack-query@0.11.0(@tanstack/query-core@5.102.8)(@tanstack/react-query@5.102.8(react@19.2.8))(jotai@2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): dependencies: - '@tanstack/query-core': 5.102.2 - jotai: 2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + '@tanstack/query-core': 5.102.8 + jotai: 2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@tanstack/react-query': 5.102.2(react@19.2.8) + '@tanstack/react-query': 5.102.8(react@19.2.8) react: 19.2.8 - jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8): + jotai@2.20.3(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8): optionalDependencies: '@babel/core': 7.29.7(supports-color@11.0.0) '@babel/template': 7.29.7 @@ -14174,12 +14245,14 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@5.3.0: + js-yaml@5.4.1: dependencies: argparse: 2.0.1 jsdoc-type-pratt-parser@7.2.0: {} + jsdoc-type-pratt-parser@7.3.0: {} + jsdoc-type-pratt-parser@8.0.0: {} jsesc@3.1.0: {} @@ -14222,16 +14295,16 @@ snapshots: khroma@2.1.0: {} - knip@6.32.2: + knip@6.34.0: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - formatly: 0.3.0 - get-tsconfig: 4.14.1 + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.0 + get-tsconfig: 4.14.3 jiti: 2.7.0 - oxc-parser: 0.143.0 + oxc-parser: 0.147.0 oxc-resolver: 11.24.2 - picomatch: 4.0.5 - smol-toml: 1.7.1 + picomatch: 4.0.7 + smol-toml: 1.8.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 unbash: 4.0.10 @@ -14244,7 +14317,7 @@ snapshots: kolorist@1.8.0: {} - ky@2.0.2: {} + ky@2.1.0: {} launch-ide@1.4.5: dependencies: @@ -14409,7 +14482,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loro-crdt@1.14.1: {} + loro-crdt@1.15.1: {} loupe@3.2.1: {} @@ -14650,14 +14723,14 @@ snapshots: mdn-data@2.29.0: {} - mediabunny@1.55.2: + mediabunny@1.55.5: dependencies: '@types/dom-mediacapture-transform': 0.1.11 '@types/dom-webcodecs': 0.1.13 merge2@1.4.1: {} - mermaid@11.17.0: + mermaid@11.17.2: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.3 @@ -14982,27 +15055,27 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.4 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.10.40 - caniuse-lite: 1.0.30001799 + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 postcss: 8.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@11.0.0))(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 '@playwright/test': 1.62.1 - sharp: 0.35.3(@types/node@25.9.5) + sharp: 0.35.4(@types/node@25.9.5) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -15018,7 +15091,9 @@ snapshots: node-releases@2.0.50: {} - node@runtime:22.23.2: {} + node-releases@2.0.54: {} + + node@runtime:24.20.0: {} normalize-package-data@8.0.0: dependencies: @@ -15034,12 +15109,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.10.0(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + nuqs@2.10.1(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@standard-schema/spec': 1.1.0 react: 19.2.8 optionalDependencies: - next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) object-assign@4.1.1: {} @@ -15090,6 +15165,15 @@ snapshots: powershell-utils: 0.2.0 wsl-utils: 1.0.0 + open@11.0.2: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.2.1 + wsl-utils: 1.0.0 + openapi-types@12.1.3: {} optionator@0.9.4: @@ -15137,29 +15221,29 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-parser@0.143.0: + oxc-parser@0.147.0: dependencies: - '@oxc-project/types': 0.143.0 + '@oxc-project/types': 0.147.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.143.0 - '@oxc-parser/binding-android-arm64': 0.143.0 - '@oxc-parser/binding-darwin-arm64': 0.143.0 - '@oxc-parser/binding-darwin-x64': 0.143.0 - '@oxc-parser/binding-freebsd-x64': 0.143.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 - '@oxc-parser/binding-linux-arm64-musl': 0.143.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 - '@oxc-parser/binding-linux-x64-gnu': 0.143.0 - '@oxc-parser/binding-linux-x64-musl': 0.143.0 - '@oxc-parser/binding-openharmony-arm64': 0.143.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 - '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 oxc-resolver@11.21.2: optionalDependencies: @@ -15205,7 +15289,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -15228,9 +15312,9 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.64.0 '@oxfmt/binding-win32-ia32-msvc': 0.64.0 '@oxfmt/binding-win32-x64-msvc': 0.64.0 - vite-plus: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - oxfmt@0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)): + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -15253,7 +15337,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.64.0 '@oxfmt/binding-win32-ia32-msvc': 0.64.0 '@oxfmt/binding-win32-x64-msvc': 0.64.0 - vite-plus: 0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0) oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -15264,7 +15348,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.79.0 '@oxlint/binding-android-arm64': 1.79.0 @@ -15286,9 +15370,9 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.79.0 '@oxlint/binding-win32-x64-msvc': 1.79.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)): + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.79.0 '@oxlint/binding-android-arm64': 1.79.0 @@ -15310,7 +15394,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.79.0 '@oxlint/binding-win32-x64-msvc': 1.79.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0) p-event@6.0.1: dependencies: @@ -15328,6 +15412,8 @@ snapshots: package-manager-detector@1.6.0: {} + package-manager-detector@1.8.0: {} + pad-right@0.2.2: dependencies: repeat-string: 1.6.1 @@ -15417,6 +15503,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + pinyin-pro@3.29.3: {} pkg-types@1.3.1: @@ -15479,6 +15567,8 @@ snapshots: powershell-utils@0.2.0: {} + powershell-utils@0.2.1: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -15524,7 +15614,7 @@ snapshots: dependencies: react: 19.2.8 - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: '@nolyfill/side-channel@1.0.44' @@ -15820,7 +15910,7 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remend@1.3.0: {} + remend@1.3.1: {} repeat-string@1.6.1: {} @@ -15910,37 +16000,37 @@ snapshots: server-only@0.0.1: {} - sharp@0.35.3(@types/node@25.9.5): + sharp@0.35.4(@types/node@25.9.5): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 25.9.5 optional: true @@ -15952,14 +16042,14 @@ snapshots: shell-quote@1.9.0: {} - shiki@4.4.2: + shiki@4.4.3: dependencies: - '@shikijs/core': 4.4.2 - '@shikijs/engine-javascript': 4.4.2 - '@shikijs/engine-oniguruma': 4.4.2 - '@shikijs/langs': 4.4.2 - '@shikijs/themes': 4.4.2 - '@shikijs/types': 4.4.2 + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -15987,7 +16077,7 @@ snapshots: size-sensor@1.0.3: {} - smol-toml@1.7.1: {} + smol-toml@1.8.0: {} socket.io-client@4.8.3(supports-color@11.0.0): dependencies: @@ -16055,7 +16145,7 @@ snapshots: stdin-discarder@0.3.2: {} - storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) @@ -16076,19 +16166,18 @@ snapshots: ws: 8.21.3 optionalDependencies: '@types/react': 19.2.18 - vite-plus: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - bufferutil - react - utf-8-validate - streamdown@2.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@11.0.0): + streamdown@2.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@11.0.0): dependencies: clsx: 2.1.1 hast-util-to-jsx-runtime: 2.3.6(supports-color@11.0.0) html-url-attributes: 3.0.1 marked: 17.0.6 - mermaid: 11.17.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) rehype-harden: 1.1.8 @@ -16097,7 +16186,7 @@ snapshots: remark-gfm: 4.0.1(supports-color@11.0.0) remark-parse: 11.0.0(supports-color@11.0.0) remark-rehype: 11.1.2 - remend: 1.3.0 + remend: 1.3.1 tailwind-merge: 3.6.0 unified: 11.0.5 unist-util-visit: 5.1.0 @@ -16251,8 +16340,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@2.1.0: {} @@ -16262,11 +16351,11 @@ snapshots: tinyspy@4.0.4: {} - tldts-core@7.4.10: {} + tldts-core@7.4.11: {} - tldts@7.4.10: + tldts@7.4.11: dependencies: - tldts-core: 7.4.10 + tldts-core: 7.4.11 to-regex-range@5.0.1: dependencies: @@ -16324,7 +16413,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.12: + tsx@4.23.13: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -16449,7 +16538,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.17.0 - picomatch: 4.0.5 + picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 update-browserslist-db@1.2.3(browserslist@4.28.4): @@ -16458,6 +16547,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.3.2(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -16523,21 +16618,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@1.0.0-beta.8(@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + vinext@1.0.0-beta.8(@vitejs/plugin-react@6.1.1(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: - '@unpic/react': 1.0.2(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@unpic/react': 1.0.2(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vercel/og': 0.8.6 '@vinext/types': 1.0.0-beta.2 - '@vitejs/plugin-react': 6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitejs/plugin-react': 6.1.1(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) ipaddr.js: 2.4.0 magic-string: 0.30.21 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vite-plugin-commonjs: 0.10.4 web-vitals: 4.2.4 optionalDependencies: - '@vitejs/plugin-rsc': 0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@vitejs/plugin-rsc': 0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) react-server-dom-webpack: 19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - next @@ -16555,9 +16650,9 @@ snapshots: fast-glob: 3.3.3 magic-string: 0.30.21 - vite-plugin-inspect@12.0.2(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(srvx@0.12.5): + vite-plugin-inspect@12.0.2(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(srvx@0.12.5): dependencies: - '@vitejs/devtools-kit': 0.4.1(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(srvx@0.12.5) + '@vitejs/devtools-kit': 0.4.1(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(srvx@0.12.5) ansis: 4.3.1 error-stack-parser-es: 2.0.1 obug: 2.1.3 @@ -16566,47 +16661,47 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.2 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' transitivePeerDependencies: - '@modelcontextprotocol/sdk' - srvx - typescript - vite-plugin-storybook-nextjs@3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0): + vite-plugin-storybook-nextjs@3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(supports-color@11.0.0): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 - next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + next: 16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) ts-dedent: 2.3.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' - vite-tsconfig-paths: 5.1.4(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(supports-color@11.0.0) + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' + vite-tsconfig-paths: 5.1.4(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(supports-color@11.0.0) transitivePeerDependencies: - supports-color - typescript - vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): + vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.146.0 '@oxlint/plugins': 1.79.0 - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) - oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) - oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2001 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) optionalDependencies: - '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 @@ -16646,26 +16741,26 @@ snapshots: - vite - yaml - vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0): + vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.146.0 '@oxlint/plugins': 1.79.0 - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) - oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)) - oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)) + '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0) + oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2001 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.12.0) optionalDependencies: - '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 @@ -16705,26 +16800,26 @@ snapshots: - vite - yaml - vite-tsconfig-paths@5.1.4(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(supports-color@11.0.0): + vite-tsconfig-paths@5.1.4(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(supports-color@11.0.0): dependencies: debug: 4.4.3(supports-color@11.0.0) globrex: 0.1.2 tsconfck: 3.1.6(@typescript/typescript6@6.0.2) optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' transitivePeerDependencies: - supports-color - typescript - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' vitest-browser-react@2.2.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) @@ -16733,12 +16828,12 @@ snapshots: dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0) - vitest@4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(happy-dom@20.11.6): + vitest@4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -16749,27 +16844,27 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 - '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) - '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) - happy-dom: 20.11.6 + happy-dom: 20.12.0 transitivePeerDependencies: - msw - vitest@4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.11.6): + vitest@4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(happy-dom@20.12.0): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -16780,20 +16875,20 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 - '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) - '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.11) '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) - happy-dom: 20.11.6 + happy-dom: 20.12.0 transitivePeerDependencies: - msw @@ -16940,6 +17035,8 @@ snapshots: zod@4.4.3: {} + zod@4.5.4: {} + zrender@6.1.0: dependencies: tslib: 2.3.0 @@ -16966,8 +17063,8 @@ snapshots: zwitch@2.0.4: {} time: - '@amplitude/analytics-browser@2.45.6': '2026-08-12T17:50:48.412Z' - '@amplitude/plugin-session-replay-browser@1.33.8': '2026-08-12T17:46:12.315Z' + '@amplitude/analytics-browser@2.45.8': '2026-08-27T22:21:54.725Z' + '@amplitude/plugin-session-replay-browser@1.35.0': '2026-08-31T19:27:50.780Z' '@axe-core/playwright@4.13.0': '2026-08-11T17:07:40.763Z' '@base-ui/react@1.7.0': '2026-08-04T09:55:20.089Z' '@chromatic-com/storybook@5.3.0': '2026-08-06T08:33:46.580Z' @@ -16990,7 +17087,7 @@ time: '@lexical/selection@0.47.0': '2026-07-09T21:14:16.798Z' '@lexical/text@0.47.0': '2026-07-09T21:14:30.676Z' '@lexical/utils@0.47.0': '2026-07-09T21:14:36.426Z' - '@mediabunny/mp3-encoder@1.55.2': '2026-08-21T18:21:45.711Z' + '@mediabunny/mp3-encoder@1.55.5': '2026-08-31T12:17:39.218Z' '@monaco-editor/react@4.7.0': '2025-02-13T16:13:41.390Z' '@napi-rs/keyring@1.3.0': '2026-04-30T09:56:44.246Z' '@orpc/client@1.15.0': '2026-08-08T13:52:09.241Z' @@ -17000,7 +17097,7 @@ time: '@playwright/test@1.62.1': '2026-07-30T16:36:55.324Z' '@remixicon/react@4.9.0': '2026-01-29T10:53:18.993Z' '@rgrove/parse-xml@4.2.3': '2026-07-26T23:12:17.833Z' - '@sentry/react@10.70.0': '2026-08-10T11:05:59.874Z' + '@sentry/react@10.73.0': '2026-08-31T16:57:44.443Z' '@storybook/addon-a11y@10.5.10': '2026-08-20T10:45:18.990Z' '@storybook/addon-docs@10.5.10': '2026-08-20T10:48:20.529Z' '@storybook/addon-links@10.5.10': '2026-08-20T10:44:25.582Z' @@ -17016,16 +17113,16 @@ time: '@t3-oss/env-nextjs@0.13.11': '2026-03-22T19:16:09.026Z' '@tailwindcss/postcss@4.3.3': '2026-07-16T12:03:56.054Z' '@tailwindcss/vite@4.3.3': '2026-07-16T12:04:06.316Z' - '@tanstack/eslint-plugin-query@5.102.2': '2026-08-23T18:00:24.776Z' + '@tanstack/eslint-plugin-query@5.102.8': '2026-08-27T16:07:09.822Z' '@tanstack/form-core@1.33.5': '2026-08-11T12:45:38.255Z' - '@tanstack/query-core@5.102.2': '2026-08-23T18:00:26.529Z' + '@tanstack/query-core@5.102.8': '2026-08-27T16:06:49.682Z' '@tanstack/react-form@1.33.5': '2026-08-11T12:45:38.642Z' '@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z' - '@tanstack/react-query@5.102.2': '2026-08-23T18:00:30.589Z' + '@tanstack/react-query@5.102.8': '2026-08-27T16:06:57.089Z' '@tanstack/react-virtual@3.14.10': '2026-08-18T15:06:28.045Z' '@testing-library/dom@10.4.1': '2025-07-27T13:23:37.151Z' '@testing-library/jest-dom@6.9.1': '2025-10-01T20:04:22.720Z' - '@testing-library/react@16.3.2': '2026-01-19T10:59:08.185Z' + '@testing-library/react@16.3.3': '2026-08-27T17:41:18.735Z' '@testing-library/user-event@14.6.6': '2026-08-22T02:06:46.844Z' '@tsslint/cli@3.1.4': '2026-06-16T18:21:29.075Z' '@tsslint/compat-eslint@3.1.4': '2026-06-16T18:21:23.786Z' @@ -17038,9 +17135,9 @@ time: '@types/react-dom@19.2.5': '2026-08-23T21:05:23.671Z' '@types/react@19.2.18': '2026-07-30T21:54:03.456Z' '@types/sortablejs@1.15.9': '2025-10-24T04:31:45.132Z' - '@typescript-eslint/parser@8.67.0': '2026-08-10T17:22:16.082Z' + '@typescript-eslint/parser@8.69.0': '2026-08-31T17:09:13.713Z' '@typescript/typescript6@6.0.2': '2026-07-06T18:06:47.459Z' - '@vitejs/plugin-react@6.1.0': '2026-08-20T02:49:46.306Z' + '@vitejs/plugin-react@6.1.1': '2026-08-28T03:30:56.619Z' '@vitejs/plugin-rsc@0.5.34': '2026-08-07T07:38:06.175Z' '@vitest/browser-playwright@4.1.11': '2026-08-18T14:22:33.004Z' '@vitest/coverage-v8@4.1.11': '2026-08-18T14:22:18.896Z' @@ -17067,7 +17164,7 @@ time: embla-carousel-fade@8.6.0: '2025-04-04T17:37:50.278Z' embla-carousel-react@8.6.0: '2025-04-04T17:37:53.976Z' emoji-mart@5.6.0: '2024-04-25T14:22:21.440Z' - es-toolkit@1.51.0: '2026-08-17T02:49:29.399Z' + es-toolkit@1.52.0: '2026-08-28T07:22:17.687Z' eslint-markdown@0.12.1: '2026-07-10T23:35:26.055Z' eslint-plugin-antfu@3.2.3: '2026-05-11T02:24:38.348Z' eslint-plugin-better-tailwindcss@4.7.0: '2026-07-19T13:26:01.366Z' @@ -17078,21 +17175,21 @@ time: eslint-plugin-markdown-preferences@0.41.1: '2026-04-09T23:28:41.552Z' eslint-plugin-n@18.3.0: '2026-08-08T14:03:55.663Z' eslint-plugin-no-barrel-files@1.3.1: '2026-04-12T18:28:18.653Z' - eslint-plugin-perfectionist@5.10.1: '2026-08-04T05:40:14.988Z' + eslint-plugin-perfectionist@5.11.0: '2026-08-31T09:02:33.026Z' eslint-plugin-pnpm@1.8.0: '2026-08-13T06:26:51.536Z' eslint-plugin-regexp@3.1.1: '2026-06-25T23:07:43.989Z' eslint-plugin-storybook@10.5.10: '2026-08-20T10:46:43.128Z' eslint-plugin-toml@1.5.0: '2026-07-25T01:46:13.957Z' eslint-plugin-unicorn@71.1.0: '2026-07-06T22:07:48.695Z' eslint-plugin-yml@3.8.1: '2026-08-05T03:50:22.441Z' - eslint@10.9.0: '2026-08-21T13:07:47.620Z' + eslint@10.9.1: '2026-08-24T18:21:58.062Z' eventsource-parser@3.1.1: '2026-08-10T15:55:29.938Z' fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z' - foxact@0.3.9: '2026-08-22T13:25:04.932Z' + foxact@0.3.10: '2026-08-27T16:19:48.187Z' fuse.js@7.5.0: '2026-07-13T17:23:36.705Z' - happy-dom@20.11.6: '2026-08-19T23:45:39.849Z' + happy-dom@20.12.0: '2026-08-29T16:12:16.364Z' hast-util-to-jsx-runtime@2.3.6: '2025-03-05T11:30:29.166Z' - hono@4.13.3: '2026-08-18T10:56:09.752Z' + hono@4.13.5: '2026-08-26T02:00:22.990Z' html-entities@2.6.0: '2025-03-30T15:40:10.885Z' html-to-image@1.11.13: '2025-02-14T01:43:48.709Z' i18next-resources-to-backend@1.2.3: '2026-07-31T15:07:13.021Z' @@ -17101,34 +17198,34 @@ time: immer@11.1.18: '2026-08-19T07:25:22.934Z' jotai-scope@0.11.0: '2026-05-13T18:43:15.331Z' jotai-tanstack-query@0.11.0: '2025-08-01T02:55:49.826Z' - jotai@2.20.2: '2026-07-14T13:52:11.083Z' + jotai@2.20.3: '2026-08-24T07:26:18.202Z' js-cookie@3.0.8: '2026-05-29T10:51:39.065Z' - js-yaml@5.3.0: '2026-08-14T09:31:39.879Z' + js-yaml@5.4.1: '2026-08-26T19:31:37.034Z' jsonschema@1.5.0: '2025-01-07T15:09:11.287Z' katex@0.17.0: '2026-05-22T08:06:26.967Z' - knip@6.32.2: '2026-08-11T20:34:19.949Z' - ky@2.0.2: '2026-04-21T08:58:46.923Z' + knip@6.34.0: '2026-08-31T22:54:33.744Z' + ky@2.1.0: '2026-08-28T13:10:48.983Z' lexical-code-no-prism@0.41.0: '2026-03-08T16:50:40.266Z' lexical@0.47.0: '2026-07-09T21:11:46.237Z' lockfile@1.0.4: '2018-04-17T00:36:12.565Z' - loro-crdt@1.14.1: '2026-08-10T11:39:06.910Z' - mediabunny@1.55.2: '2026-08-21T18:24:30.681Z' - mermaid@11.17.0: '2026-08-19T09:20:08.814Z' + loro-crdt@1.15.1: '2026-08-29T09:52:09.072Z' + mediabunny@1.55.5: '2026-08-31T12:20:23.255Z' + mermaid@11.17.2: '2026-08-25T11:52:39.930Z' mime@4.1.0: '2025-09-12T17:53:01.376Z' mitt@3.0.1: '2023-07-04T17:31:47.638Z' motion@12.43.0: '2026-07-28T14:29:31.291Z' negotiator@1.1.0: '2026-08-20T16:45:05.235Z' next-themes@0.4.6: '2025-03-11T21:02:05.882Z' - next@16.3.3: '2026-08-25T15:32:19.558Z' - nuqs@2.10.0: '2026-08-20T08:10:12.660Z' - open@11.0.1: '2026-08-13T23:39:43.829Z' + next@16.3.4: '2026-08-31T20:00:51.381Z' + nuqs@2.10.1: '2026-08-25T10:59:58.402Z' + open@11.0.2: '2026-08-29T13:12:01.838Z' ora@9.4.1: '2026-06-22T12:24:49.225Z' picocolors@1.1.1: '2024-10-16T18:20:03.921Z' pinyin-pro@3.29.3: '2026-08-19T01:06:36.944Z' playwright@1.62.1: '2026-07-30T16:38:49.134Z' postcss@8.5.26: '2026-08-06T08:33:00.043Z' qrcode.react@4.2.0: '2024-12-11T17:22:40.569Z' - qs@6.15.3: '2026-06-24T20:03:49.752Z' + qs@6.16.0: '2026-08-29T23:50:15.803Z' react-dom@19.2.8: '2026-07-21T15:41:41.267Z' react-easy-crop@6.2.3: '2026-07-24T14:14:31.126Z' react-i18next@17.0.12: '2026-08-20T10:15:43.090Z' @@ -17143,17 +17240,17 @@ time: remark-directive@4.0.0: '2025-02-27T15:15:20.630Z' scheduler@0.27.0: '2025-10-01T21:39:15.208Z' server-only@0.0.1: '2022-09-03T01:07:26.139Z' - shiki@4.4.2: '2026-08-05T00:41:14.729Z' + shiki@4.4.3: '2026-08-10T04:57:41.135Z' socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z' sortablejs@1.15.7: '2026-02-11T22:42:31.720Z' std-semver@1.0.8: '2026-03-09T17:23:55.795Z' storybook@10.5.10: '2026-08-20T10:52:53.637Z' - streamdown@2.5.0: '2026-03-17T17:35:05.216Z' + streamdown@2.6.0: '2026-08-24T14:07:33.027Z' string-ts@2.3.1: '2025-11-28T17:33:10.099Z' tailwind-merge@3.6.0: '2026-05-10T12:56:43.142Z' tailwindcss@4.3.3: '2026-07-16T12:03:35.267Z' - tldts@7.4.10: '2026-07-30T23:11:08.153Z' - tsx@4.23.12: '2026-08-10T03:41:31.093Z' + tldts@7.4.11: '2026-08-24T07:31:22.093Z' + tsx@4.23.13: '2026-08-30T00:46:16.265Z' typescript@7.0.2: '2026-07-08T15:55:18.431Z' uglify-js@3.19.3: '2024-08-29T13:49:01.316Z' undici@7.29.0: '2026-07-24T12:52:58.701Z' @@ -17166,6 +17263,6 @@ time: vitest-browser-react@2.2.0: '2026-04-05T06:56:34.635Z' vitest-canvas-mock@1.1.5: '2026-08-13T07:03:06.784Z' vitest@4.1.11: '2026-08-18T14:27:07.240Z' - zod@4.4.3: '2026-05-04T07:06:40.819Z' + zod@4.5.4: '2026-08-29T17:55:42.775Z' zundo@2.3.0: '2024-11-17T16:35:11.372Z' zustand@5.0.15: '2026-08-13T00:39:55.466Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4902228a77d..d977dd3d173 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,11 +46,11 @@ overrides: esbuild@>=0.27.3 <0.28.1: ^0.28.2 is-core-module: npm:@nolyfill/is-core-module@^1.0.39 js-yaml@<=4.1.1: ^4.1.2 - picomatch@>=4.0.0 <4.0.4: 4.0.5 + picomatch@>=4.0.0 <4.0.4: 4.0.7 postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4 postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.5 postcss@<8.5.10: ^8.5.10 - rollup@>=4.0.0 <4.59.0: 4.62.5 + rollup@>=4.0.0 <4.59.0: 4.63.1 safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 solid-js: 1.9.15 @@ -62,8 +62,8 @@ overrides: yaml@>=2.0.0 <2.8.3: 2.9.0 yauzl@<3.2.1: 3.2.1 catalog: - '@amplitude/analytics-browser': 2.45.6 - '@amplitude/plugin-session-replay-browser': 1.33.8 + '@amplitude/analytics-browser': 2.45.8 + '@amplitude/plugin-session-replay-browser': 1.35.0 '@axe-core/playwright': 4.13.0 '@base-ui/react': 1.7.0 '@chromatic-com/storybook': 5.3.0 @@ -88,7 +88,7 @@ catalog: '@lexical/selection': 0.47.0 '@lexical/text': 0.47.0 '@lexical/utils': 0.47.0 - '@mediabunny/mp3-encoder': 1.55.2 + '@mediabunny/mp3-encoder': 1.55.5 '@monaco-editor/react': 4.7.0 '@napi-rs/keyring': 1.3.0 '@orpc/client': 1.15.0 @@ -98,7 +98,7 @@ catalog: '@playwright/test': 1.62.1 '@remixicon/react': 4.9.0 '@rgrove/parse-xml': 4.2.3 - '@sentry/react': 10.70.0 + '@sentry/react': 10.73.0 '@storybook/addon-a11y': 10.5.10 '@storybook/addon-docs': 10.5.10 '@storybook/addon-links': 10.5.10 @@ -114,16 +114,16 @@ catalog: '@t3-oss/env-nextjs': 0.13.11 '@tailwindcss/postcss': 4.3.3 '@tailwindcss/vite': 4.3.3 - '@tanstack/eslint-plugin-query': 5.102.2 + '@tanstack/eslint-plugin-query': 5.102.8 '@tanstack/form-core': 1.33.5 - '@tanstack/query-core': 5.102.2 + '@tanstack/query-core': 5.102.8 '@tanstack/react-form': 1.33.5 '@tanstack/react-hotkeys': 0.10.0 - '@tanstack/react-query': 5.102.2 + '@tanstack/react-query': 5.102.8 '@tanstack/react-virtual': 3.14.10 '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 - '@testing-library/react': 16.3.2 + '@testing-library/react': 16.3.3 '@testing-library/user-event': 14.6.6 '@tsslint/cli': 3.1.4 '@tsslint/compat-eslint': 3.1.4 @@ -136,9 +136,9 @@ catalog: '@types/react': 19.2.18 '@types/react-dom': 19.2.5 '@types/sortablejs': 1.15.9 - '@typescript-eslint/parser': 8.67.0 + '@typescript-eslint/parser': 8.69.0 '@typescript/native': npm:typescript@7.0.2 - '@vitejs/plugin-react': 6.1.0 + '@vitejs/plugin-react': 6.1.1 '@vitejs/plugin-rsc': 0.5.34 '@vitest/browser-playwright': 4.1.11 '@vitest/coverage-v8': 4.1.11 @@ -164,8 +164,8 @@ catalog: embla-carousel-fade: 8.6.0 embla-carousel-react: 8.6.0 emoji-mart: 5.6.0 - es-toolkit: 1.51.0 - eslint: 10.9.0 + es-toolkit: 1.52.0 + eslint: 10.9.1 eslint-markdown: 0.12.1 eslint-plugin-antfu: 3.2.3 eslint-plugin-better-tailwindcss: 4.7.0 @@ -176,7 +176,7 @@ catalog: eslint-plugin-markdown-preferences: 0.41.1 eslint-plugin-n: 18.3.0 eslint-plugin-no-barrel-files: 1.3.1 - eslint-plugin-perfectionist: 5.10.1 + eslint-plugin-perfectionist: 5.11.0 eslint-plugin-pnpm: 1.8.0 eslint-plugin-regexp: 3.1.1 eslint-plugin-storybook: 10.5.10 @@ -185,46 +185,46 @@ catalog: eslint-plugin-yml: 3.8.1 eventsource-parser: 3.1.1 fast-deep-equal: 3.1.3 - foxact: 0.3.9 + foxact: 0.3.10 fuse.js: 7.5.0 - happy-dom: 20.11.6 + happy-dom: 20.12.0 hast-util-to-jsx-runtime: 2.3.6 - hono: 4.13.3 + hono: 4.13.5 html-entities: 2.6.0 html-to-image: 1.11.13 i18next: 26.4.0 i18next-resources-to-backend: 1.2.3 iconify-import-svg: 0.2.0 immer: 11.1.18 - jotai: 2.20.2 + jotai: 2.20.3 jotai-scope: 0.11.0 jotai-tanstack-query: 0.11.0 js-cookie: 3.0.8 - js-yaml: 5.3.0 + js-yaml: 5.4.1 jsonschema: 1.5.0 katex: 0.17.0 - knip: 6.32.2 - ky: 2.0.2 + knip: 6.34.0 + ky: 2.1.0 lexical: 0.47.0 lockfile: 1.0.4 - loro-crdt: 1.14.1 - mediabunny: 1.55.2 - mermaid: 11.17.0 + loro-crdt: 1.15.1 + mediabunny: 1.55.5 + mermaid: 11.17.2 mime: 4.1.0 mitt: 3.0.1 motion: 12.43.0 negotiator: 1.1.0 - next: 16.3.3 + next: 16.3.4 next-themes: 0.4.6 - nuqs: 2.10.0 - open: 11.0.1 + nuqs: 2.10.1 + open: 11.0.2 ora: 9.4.1 picocolors: 1.1.1 pinyin-pro: 3.29.3 playwright: 1.62.1 postcss: 8.5.26 qrcode.react: 4.2.0 - qs: 6.15.3 + qs: 6.16.0 react: 19.2.8 react-dom: 19.2.8 react-easy-crop: 6.2.3 @@ -239,17 +239,17 @@ catalog: remark-directive: 4.0.0 scheduler: 0.27.0 server-only: 0.0.1 - shiki: 4.4.2 + shiki: 4.4.3 socket.io-client: 4.8.3 sortablejs: 1.15.7 std-semver: 1.0.8 storybook: 10.5.10 - streamdown: 2.5.0 + streamdown: 2.6.0 string-ts: 2.3.1 tailwind-merge: 3.6.0 tailwindcss: 4.3.3 - tldts: 7.4.10 - tsx: 4.23.12 + tldts: 7.4.11 + tsx: 4.23.13 typescript: npm:@typescript/typescript6@6.0.2 uglify-js: 3.19.3 undici: 7.29.0 @@ -263,7 +263,7 @@ catalog: vitest: 4.1.11 vitest-browser-react: 2.2.0 vitest-canvas-mock: 1.1.5 - zod: 4.4.3 + zod: 4.5.4 zundo: 2.3.0 zustand: 5.0.15 peerDependencyRules: diff --git a/web/Dockerfile b/web/Dockerfile index 9ddb06b8ed5..b60983d5665 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,5 +1,6 @@ # base image -FROM node:22.22.1-alpine AS base +ARG NODE_VERSION=24.20.0 +FROM node:${NODE_VERSION}-alpine AS base LABEL maintainer="takatost@gmail.com" # if you located in China, you can use aliyun mirror to speed up diff --git a/web/Dockerfile.dockerignore b/web/Dockerfile.dockerignore index 115f4303fa9..4a75679e91c 100644 --- a/web/Dockerfile.dockerignore +++ b/web/Dockerfile.dockerignore @@ -2,7 +2,6 @@ !package.json !pnpm-lock.yaml !pnpm-workspace.yaml -!.nvmrc !web/ !web/** !e2e/ diff --git a/web/README.md b/web/README.md index cd4cc161b1e..cb5abb83bc4 100644 --- a/web/README.md +++ b/web/README.md @@ -6,7 +6,7 @@ This is a [Next.js] application with [vinext] as the default local development s ### Run by source code -The required Node.js and pnpm versions are pinned by the repository root `.nvmrc` and `packageManager` field. [Vite+] is also available for repository checks and tests; use its official documentation as the installation reference. +The required Node.js and pnpm versions are pinned by the repository root `devEngines.runtime` and `packageManager` fields. [Vite+] is also available for repository checks and tests; use its official documentation as the installation reference. - [Node.js] - [pnpm] @@ -20,7 +20,7 @@ pnpm install ``` > [!NOTE] -> JavaScript dependencies are managed by the workspace files at the repository root: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and `.nvmrc`. +> JavaScript dependencies are managed by the workspace files at the repository root: `package.json`, `pnpm-lock.yaml`, and `pnpm-workspace.yaml`. > Install dependencies and run the commands below from the repository root. Then, configure the environment variables. diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 9a52e32d0e7..8c6acc78866 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -238,11 +238,9 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) - it('should allow users with access point permission to open access point directly', async () => { + it('should allow access point pages without app deploy or app ACL permissions', async () => { mockPathname = '/app/app-1/access-point' - mockFetchAppDetailDirect.mockResolvedValue( - createAppDetail({ permission_keys: [AppACLPermission.AccessPoint] }), - ) + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) render( @@ -256,44 +254,6 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) - it('should redirect access point pages when access point permission is missing', async () => { - mockPathname = '/app/app-1/access-point' - mockFetchAppDetailDirect.mockResolvedValue( - createAppDetail({ permission_keys: [AppACLPermission.Monitor] }), - ) - - render( - -
App page content
-
, - ) - - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') - }) - expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() - }) - - it('should keep access point content hidden while redirecting cached app data without permission', async () => { - mockPathname = '/app/app-1/access-point' - useStore - .getState() - .setAppDetail(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) - - render( - -
App page content
-
, - ) - - expect(screen.queryByText('App page content')).not.toBeInTheDocument() - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') - }) - expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() - }) - it('should redirect deploy pages when app deploy ACL permission is missing', async () => { mockPathname = '/app/app-1/deploy' mockFetchAppDetailDirect.mockResolvedValue( @@ -357,7 +317,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() @@ -528,7 +488,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index df55021f289..7f9f6973ca8 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -78,28 +78,8 @@ const AppDetailLayout: FC = (props) => { appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null const pageTitle = appDetailPageTitle(pathname, t) const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined - const isAppACLContextReady = - !!routeAppDetail && - !!currentWorkspace.id && - !isLoadingCurrentWorkspace && - !isLoadingWorkspacePermissionKeys && - !isLoadingAppDetail - const appACLCapabilities = React.useMemo( - () => - routeAppDetail && isAppACLContextReady - ? getAppACLCapabilities(routeAppDetail.permission_keys, { - currentUserId, - resourceMaintainer: routeAppDetail.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }) - : null, - [currentUserId, isAppACLContextReady, isRbacEnabled, routeAppDetail, workspacePermissionKeys], - ) const shouldBlockAgentResourceAccess = routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config') - const shouldBlockAccessPointAccess = - pathname.endsWith('/access-point') && !appACLCapabilities?.canAccessPoint useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`) @@ -140,16 +120,28 @@ const AppDetailLayout: FC = (props) => { }, [appId, router, setAppDetail]) useEffect(() => { - if (!routeAppDetail || !isAppACLContextReady || !appACLCapabilities) return + if ( + !routeAppDetail || + !currentWorkspace.id || + isLoadingCurrentWorkspace || + isLoadingWorkspacePermissionKeys || + isLoadingAppDetail + ) + return if (routeAppDetail.id !== appId) return + const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, { + currentUserId, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }) const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') const isLogsPath = pathname.endsWith('logs') const isAnnotationsPath = pathname.endsWith('annotations') const isOverviewPath = pathname.endsWith('overview') const isAccessConfigPath = pathname.endsWith('access-config') const isDeployPath = pathname.endsWith('deploy') - const isAccessPointPath = pathname.endsWith('access-point') if ( (isLayoutPath && !appACLCapabilities.canAccessLayout) || (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || @@ -158,8 +150,7 @@ const AppDetailLayout: FC = (props) => { (isAccessConfigPath && (routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || (isDeployPath && - (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) || - (isAccessPointPath && !appACLCapabilities.canAccessPoint) + (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) ) { router.replace( getRedirectionPath(routeAppDetail, { @@ -189,12 +180,14 @@ const AppDetailLayout: FC = (props) => { if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail({ ...appDetailRes, enable_sso: false }) }, [ - appACLCapabilities, appDetail?.id, appDetailRes, appId, currentUserId, - isAppACLContextReady, + currentWorkspace.id, + isLoadingAppDetail, + isLoadingCurrentWorkspace, + isLoadingWorkspacePermissionKeys, isRbacEnabled, pathname, routeAppDetail, @@ -205,7 +198,7 @@ const AppDetailLayout: FC = (props) => { const isWorkflowPage = pathname.endsWith('/workflow') const content = - !appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? ( + !appDetail || shouldBlockAgentResourceAccess ? (
diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index 0ff08042648..e84d925dbdc 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -186,10 +186,7 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) - it('should render access point navigation when access point permission is granted', () => { - // Arrange - mockAppPermissionKeys = [AppACLPermission.AccessPoint] - + it('should render access point navigation using its app route', () => { // Act render() @@ -203,19 +200,6 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) - it('should hide access point navigation when access point permission is missing', () => { - // Arrange - mockAppPermissionKeys = [AppACLPermission.Monitor] - - // Act - render() - - // Assert - expect( - screen.queryByRole('link', { name: 'common.appMenus.accessPoint' }), - ).not.toBeInTheDocument() - }) - it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => { // Arrange mockAppMode = 'workflow' diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 677ac50aea4..3176e2cac21 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -120,16 +120,12 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { }, ] : []), - ...(appACLCapabilities.canAccessPoint - ? [ - { - name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), - href: `/app/${appId}/access-point`, - icon: accessPointNavIcon, - selectedIcon: accessPointNavIcon, - }, - ] - : []), + { + name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), + href: `/app/${appId}/access-point`, + icon: accessPointNavIcon, + selectedIcon: accessPointNavIcon, + }, ...(supportsAppDeploy && appACLCapabilities.canDeploy ? [ { diff --git a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx index 5f740babef4..c800deded33 100644 --- a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx @@ -21,6 +21,11 @@ describe('AccessPointCard', () => { 'data-highlighted', 'true', ) + expect(screen.getByRole('heading', { name: 'Web App' })).toHaveAttribute('title', 'Web App') + expect(screen.getByText('Web application access')).toHaveAttribute( + 'title', + 'Web application access', + ) }) it.each<[AccessPointStatus, string, boolean]>([ diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index 5cefe0aeeb7..b88bb755384 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ } as Record, workflow: { data: null as Record | null, + isError: false, isPending: false, }, webCard: vi.fn(), @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({ }, mcpCard: vi.fn(), triggerCard: vi.fn(), + useAppWorkflow: vi.fn(), })) vi.mock('react-i18next', async () => { @@ -60,7 +62,10 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/service/use-workflow', () => ({ - useAppWorkflow: () => mocks.workflow, + useAppWorkflow: (...args: unknown[]) => { + mocks.useAppWorkflow(...args) + return mocks.workflow + }, })) vi.mock('@/utils/permission', () => ({ @@ -118,6 +123,7 @@ describe('BuiltInAccessPoints', () => { } mocks.workflow = { data: null, + isError: false, isPending: false, } mocks.capabilities = { @@ -150,6 +156,7 @@ describe('BuiltInAccessPoints', () => { nodes: [{ data: { type: 'start' } }], }, }, + isError: false, isPending: false, } @@ -197,6 +204,7 @@ describe('BuiltInAccessPoints', () => { nodes: [{ data: { type: 'trigger-webhook' } }], }, }, + isError: false, isPending: false, } @@ -222,6 +230,7 @@ describe('BuiltInAccessPoints', () => { it('keeps all cards visible while the published workflow is loading', () => { mocks.workflow = { data: null, + isError: false, isPending: true, } @@ -233,4 +242,30 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'loading' }), ) }) + + it('does not show the unpublished card when loading the published workflow fails', () => { + mocks.workflow = { + data: null, + isError: true, + isPending: false, + } + + render() + + expect( + screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'), + ).not.toBeInTheDocument() + }) + + it('does not retry forbidden published workflow requests', () => { + render() + + const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as { + retry: (failureCount: number, error: unknown) => boolean + } + + expect(options.retry(0, new Response(null, { status: 403 }))).toBe(false) + expect(options.retry(0, new Response(null, { status: 500 }))).toBe(true) + expect(options.retry(3, new Response(null, { status: 500 }))).toBe(false) + }) }) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index 95847013f29..5f739e69ea2 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -39,9 +39,17 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) - const { data: workflow, isPending: workflowLoading } = useAppWorkflow( - shouldFetchWorkflow ? appId : '', - ) + const { + data: workflow, + isError: workflowError, + isPending: workflowLoading, + } = useAppWorkflow(shouldFetchWorkflow ? appId : '', { + retry: (failureCount, error) => { + if (error instanceof Response && error.status === 403) return false + + return failureCount < 3 + }, + }) const capabilities = useMemo( () => getAppACLCapabilities(appInfo?.permission_keys, { @@ -72,7 +80,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc return (
- {workflowState.isUnpublished && !workflowLoading && ( + {workflowState.isUnpublished && !workflowLoading && !workflowError && (
diff --git a/web/app/components/app/access-point/shared/access-point-card.tsx b/web/app/components/app/access-point/shared/access-point-card.tsx index 664b21a0500..3e4430dea85 100644 --- a/web/app/components/app/access-point/shared/access-point-card.tsx +++ b/web/app/components/app/access-point/shared/access-point-card.tsx @@ -72,10 +72,12 @@ export function AccessPointCard({ icon )} -

+

{title}

- {description} + + {description} +
{showStatus && ( <> diff --git a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx index 5cbd69dfc6a..48943656d07 100644 --- a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx @@ -26,7 +26,10 @@ const ClearAllAnnotationsConfirmModal: FC = ({ isShow, onHide, onConfirm !open && onHide()}>
- + {title}
diff --git a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx index 55e347dfdfa..dbaa091659f 100644 --- a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx @@ -25,7 +25,10 @@ const RemoveAnnotationConfirmModal: FC = ({ isShow, onHide, onRemove }) = !open && onHide()}>
- + {title}
diff --git a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx index 9c80ad25f11..8aa12ed5457 100644 --- a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx @@ -295,7 +295,6 @@ function renderFlow( return render( ({ + @@ -107,6 +113,23 @@ describe('FeaturesWrappedAppPublisher', () => { }) }) + it('should pass publish notification options through to onPublish', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('publish-silently-through-wrapper')) + + await waitFor(() => { + expect(mockOnPublish).toHaveBeenCalledWith(undefined, mockFeatures, { + showSuccessToast: false, + }) + }) + }) + it('should restore published features after confirmation', async () => { render( ({ })) const mockPublishToCreatorsPlatform = vi.fn() +const mockCreateWorkflowToolProvider = vi.fn() vi.mock('@/service/apps', () => ({ publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), })) +vi.mock('@/service/tools', () => ({ + createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args), + saveWorkflowToolProvider: vi.fn(), +})) + vi.mock('@/service/use-workflow', () => ({ useAppWorkflow: () => ({ data: mockPublishedWorkflow, @@ -167,9 +173,26 @@ vi.mock('@/app/components/base/amplitude', () => ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( + WorkflowToolDrawer: ({ + onCreate, + onHide, + }: { + onCreate?: (payload: Record) => void + onHide: () => void + }) => (
workflow tool drawer + @@ -818,6 +841,74 @@ describe('AppPublisher', () => { expect(screen.getByRole('dialog', { name: 'Workflow tool drawer' })).toBeInTheDocument() }) + it('should show one success toast when automatically publishing a workflow tool', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockOnPublish.mockImplementation(async (_params, options?: { showSuccessToast?: boolean }) => { + if (options?.showSuccessToast !== false) mockToastSuccess('common.api.actionSuccess') + }) + mockCreateWorkflowToolProvider.mockResolvedValue({}) + + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + fireEvent.click(screen.getByRole('button', { name: 'create-workflow-tool' })) + + await waitFor(() => { + expect(mockCreateWorkflowToolProvider).toHaveBeenCalledOnce() + }) + expect(mockOnPublish).toHaveBeenCalledWith(undefined, { showSuccessToast: false }) + expect(mockToastSuccess).toHaveBeenCalledOnce() + }) + + it('should not show a success toast when workflow tool creation fails after publishing', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockOnPublish.mockImplementation(async (_params, options?: { showSuccessToast?: boolean }) => { + if (options?.showSuccessToast !== false) mockToastSuccess('common.api.actionSuccess') + }) + mockCreateWorkflowToolProvider.mockRejectedValue(new Error('create failed')) + + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + fireEvent.click(screen.getByRole('button', { name: 'create-workflow-tool' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('create failed') + }) + expect(mockOnPublish).toHaveBeenCalledWith(undefined, { showSuccessToast: false }) + expect(mockToastSuccess).not.toHaveBeenCalled() + }) + + it('should not create a workflow tool when automatic publishing fails', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) + + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + fireEvent.click(screen.getByRole('button', { name: 'create-workflow-tool' })) + + await waitFor(() => { + expect(mockOnPublish).toHaveBeenCalledOnce() + }) + expect(mockCreateWorkflowToolProvider).not.toHaveBeenCalled() + expect(mockToastError).toHaveBeenCalledWith('publish failed') + consoleWarnSpy.mockRestore() + }) + it('should not open workflow tool drawer without tool.manage', () => { mockWorkspacePermissionKeys = [] mockAppDetail = { diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 11c0b1daf3f..9090a86bbd4 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -6,7 +6,6 @@ import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' import { PublisherActionsSection } from '../built-in-publisher/actions-section' import { PublisherSummarySection } from '../built-in-publisher/summary-section' -import { PublisherEnvironmentActionsSection } from '../environment-deployment-flow/actions-section' vi.mock('../publish-with-multiple-model', () => ({ default: ({ @@ -315,7 +314,6 @@ describe('app-publisher sections', () => { description: 'Workflow description', }} appURL="https://example.com/app" - canAccessPoint disabledFunctionButton={false} disabledFunctionTooltip="disabled" handleOpenRunConfig={handleOpenRunConfig} @@ -496,7 +494,6 @@ describe('app-publisher sections', () => { mode: AppModeEnum.WORKFLOW, }} appURL="https://example.com/app" - canAccessPoint disabledFunctionButton={false} hasHumanInputNode={false} hasTriggerNode @@ -520,49 +517,11 @@ describe('app-publisher sections', () => { ) }) - it('should hide the built-in Access Point action without permission', () => { - render( - , - ) - - expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute( - 'href', - '/app/workflow-app/deploy', - ) - }) - - it('should hide the environment Access Point action without permission', () => { - render( - , - ) - - expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument() - expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/)).toBeInTheDocument() - }) - it('should expose unavailable quick links as disabled buttons before the first publish', () => { render( void @@ -42,7 +41,6 @@ type PublisherActionsSectionProps = Pick< export function PublisherActionsSection({ appDetail, appURL, - canAccessPoint = false, disabledFunctionButton, disabledFunctionTooltip, handleOpenRunConfig, @@ -116,16 +114,14 @@ export function PublisherActionsSection({ {disabledFunctionTooltip} )} - {canAccessPoint && ( - $['common.accessPointDescription'], { ns: 'workflow' })} - link={appId ? `/app/${appId}/access-point` : undefined} - icon={} - > - {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} - - )} + $['common.accessPointDescription'], { ns: 'workflow' })} + link={appId ? `/app/${appId}/access-point` : undefined} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + {showDeploy && ( - {canAccessPoint && ( - $['common.accessPointDescription'], { ns: 'workflow' })} - link={accessPointHref} - icon={} - > - {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} - - )} + $['common.accessPointDescription'], { ns: 'workflow' })} + link={accessPointHref} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + $['common.deployDescription'], { ns: 'workflow' })} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx index 9f0b1201993..dce08832bc0 100644 --- a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx +++ b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx @@ -15,7 +15,6 @@ import { PublisherEnvironmentSummarySection } from './summary-section' type PublisherEnvironmentFlowProps = { appId?: string - canAccessPoint?: boolean deployment?: EnvironmentDeployment environmentId: string environmentName: string @@ -29,7 +28,6 @@ type PublisherEnvironmentFlowProps = { export function PublisherEnvironmentFlow({ appId, - canAccessPoint = false, deployment, environmentId, environmentName, @@ -93,7 +91,6 @@ export function PublisherEnvironmentFlow({ /> diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 4114aadb50a..ffb85da6b44 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,5 +1,6 @@ import type { AppPublisherProps, + AppPublisherPublishOptions, AppPublisherPublishParams, } from '@/app/components/app/app-publisher/types' import type { ConfigurationPublishConfig } from '@/app/components/app/configuration/hooks/configuration-lifecycle/types' @@ -26,6 +27,7 @@ type Props = Omit & { onPublish?: ( params?: AppPublisherPublishParams, features?: Features, + options?: AppPublisherPublishOptions, ) => Promise | unknown publishedConfig: ConfigurationPublishConfig resetAppConfig?: () => void @@ -88,7 +90,8 @@ const FeaturesWrappedAppPublisher = (props: Props) => { } const handlePublish = useCallback( - (params?: AppPublisherPublishParams) => { + (params?: AppPublisherPublishParams, options?: AppPublisherPublishOptions) => { + if (options) return props.onPublish?.(params, features, options) return props.onPublish?.(params, features) }, [features, props], diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 38e9b724d1e..48278b77cfc 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -17,13 +17,12 @@ export function AppPublisher(props: AppPublisherProps) { select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { + const canDeploy = getAppACLCapabilities(appDetail?.permission_keys, { currentUserId, resourceMaintainer: appDetail?.maintainer, workspacePermissionKeys, - }) - const supportsMultiEnvironment = - appDetail?.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy + }).canDeploy + const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy return ( void } export function PublisherContent({ - canAccessPoint, crossAxisOffset = 0, debugWithMultipleModel = false, disabled = false, @@ -132,7 +130,7 @@ export function PublisherContent({ hasTriggerNode, inputs, onClosePublisher: closePublisher, - onPublish: publish.handlePublish, + onPublish: publish.publishWorkflowTool, onRefreshData, outputs, toolPublished, @@ -214,7 +212,6 @@ export function PublisherContent({ actions: { appDetail, appURL, - canAccessPoint, disabledFunctionButton, disabledFunctionTooltip, handleOpenRunConfig: workflowLaunch.openDialog, @@ -239,7 +236,6 @@ export function PublisherContent({ disabled={disabled} environmentPublisher={{ appId: appDetail?.id, - canAccessPoint, deployment: selectedEnvironmentDeployment, environmentId: selectedEnvironmentId, environmentName: diff --git a/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts b/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts index 0fc2f7acb3d..6737b74ee17 100644 --- a/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts +++ b/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts @@ -1,5 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' -import type { AppPublisherProps, AppPublisherPublishParams } from '../types' +import type { + AppPublisherProps, + AppPublisherPublishOptions, + AppPublisherPublishParams, +} from '../types' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import { useHotkey } from '@tanstack/react-hotkeys' import { useQueryClient } from '@tanstack/react-query' @@ -81,43 +85,54 @@ export function usePublishController({ : publishedAt const hasPublishedVersion = Boolean(currentPublishedAt) + async function publishApp( + params?: AppPublisherPublishParams, + options?: AppPublisherPublishOptions, + ) { + await onPublish?.(params, options) + setPublished(true) + + const socket = appId ? webSocketClient.getSocket(appId) : null + if (appId) { + invalidateAppWorkflow(appId) + if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId) + } else { + console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') + } + if (socket) { + const timestamp = Date.now() + socket.emit('collaboration_event', { + type: 'app_publish_update', + data: { + action: 'published', + timestamp, + }, + timestamp, + }) + } else if (appId) { + console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) + } + + trackEvent('app_published_time', { + action_mode: 'app', + app_id: appId, + app_name: appName, + }) + } + async function handlePublish(params?: AppPublisherPublishParams) { try { - await onPublish?.(params) - setPublished(true) - - const socket = appId ? webSocketClient.getSocket(appId) : null - if (appId) { - invalidateAppWorkflow(appId) - if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId) - } else { - console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') - } - if (socket) { - const timestamp = Date.now() - socket.emit('collaboration_event', { - type: 'app_publish_update', - data: { - action: 'published', - timestamp, - }, - timestamp, - }) - } else if (appId) { - console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) - } - - trackEvent('app_published_time', { - action_mode: 'app', - app_id: appId, - app_name: appName, - }) + await publishApp(params) } catch (error) { console.warn('[app-publisher] publish failed', error) setPublished(false) } } + async function publishWorkflowTool(params?: AppPublisherPublishParams) { + await publishApp(params, { showSuccessToast: false }) + } + async function handleRestore() { try { await onRestore?.() @@ -164,6 +179,7 @@ export function usePublishController({ isWorkflowApp, published, publishedWorkflow, + publishWorkflowTool, resetPublished: () => setPublished(false), } } diff --git a/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts b/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts index d79b33cf4bb..01609e2ef8a 100644 --- a/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts +++ b/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts @@ -1,5 +1,6 @@ import type { AppPublisherPublishParams } from '../types' -import type { InputVar, Variable } from '@/app/components/workflow/types' +import type { WorkflowToolOutputVariable } from '@/app/components/tools/types' +import type { InputVar } from '@/app/components/workflow/types' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' @@ -20,7 +21,7 @@ type UseWorkflowToolParams = { hasPublishedVersion: boolean hasTriggerNode: boolean inputs?: InputVar[] - outputs?: Variable[] + outputs?: WorkflowToolOutputVariable[] toolPublished?: boolean workflowToolAvailable: boolean onClosePublisher: () => void diff --git a/web/app/components/app/app-publisher/types.ts b/web/app/components/app/app-publisher/types.ts index 8c8e9917cde..bf457fdc1f7 100644 --- a/web/app/components/app/app-publisher/types.ts +++ b/web/app/components/app/app-publisher/types.ts @@ -1,12 +1,20 @@ import type { ModelAndParameter } from '../configuration/debug/types' -import type { InputVar, Variable } from '@/app/components/workflow/types' +import type { WorkflowToolOutputVariable } from '@/app/components/tools/types' +import type { InputVar } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams +export type AppPublisherPublishOptions = { + showSuccessToast?: boolean +} + type AppPublisherPublishHandler = - | ((params?: AppPublisherPublishParams) => Promise | unknown) - | ((params?: unknown) => Promise | unknown) + | (( + params?: AppPublisherPublishParams, + options?: AppPublisherPublishOptions, + ) => Promise | unknown) + | ((params?: unknown, options?: AppPublisherPublishOptions) => Promise | unknown) type AppPublisherRestoreHandler = () => Promise | unknown @@ -25,7 +33,7 @@ export type AppPublisherProps = { crossAxisOffset?: number toolPublished?: boolean inputs?: InputVar[] - outputs?: Variable[] + outputs?: WorkflowToolOutputVariable[] onRefreshData?: () => void workflowToolAvailable?: boolean missingStartNode?: boolean diff --git a/web/app/components/app/app-publisher/workflow-tool-action/index.tsx b/web/app/components/app/app-publisher/workflow-tool-action/index.tsx index aeed4d0bf54..bf9cc3fe444 100644 --- a/web/app/components/app/app-publisher/workflow-tool-action/index.tsx +++ b/web/app/components/app/app-publisher/workflow-tool-action/index.tsx @@ -74,7 +74,10 @@ const WorkflowToolAction = ({
- + {workflowToolLabel} diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index 6b3363366eb..acd85b398ce 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -284,7 +284,7 @@ describe('SettingsModal', () => { await renderSettingsModal(dataset) // Assert - expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue( + expect(screen.getByRole('textbox', { name: 'datasetSettings.form.name' })).toHaveValue( 'Test Dataset', ) expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue( @@ -333,7 +333,7 @@ describe('SettingsModal', () => { const user = userEvent.setup() await renderSettingsModal(createDataset()) - const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' }) // Act await user.clear(nameInput) @@ -417,7 +417,7 @@ describe('SettingsModal', () => { const user = userEvent.setup() await renderSettingsModal(createDataset()) - const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' }) // Act await user.clear(nameInput) @@ -483,7 +483,7 @@ describe('SettingsModal', () => { // Act await renderSettingsModal(dataset) - const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' }) await user.clear(nameInput) await user.type(nameInput, 'Updated Internal Dataset') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index be646be30fd..a36903a222a 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -5,14 +5,14 @@ import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' import { RiCloseLine } from '@remixicon/react' import { isEqual } from 'es-toolkit/predicate' import { useQueryState } from 'nuqs' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/components/app/configuration/toast' -import Input from '@/app/components/base/input' import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' import { IndexingType } from '@/app/components/datasets/create/step-two' import IndexMethod from '@/app/components/datasets/settings/index-method' @@ -58,6 +58,7 @@ const SettingsModal: FC = ({ const translateRetrieval: RetrievalTranslate = (selector, options) => t(selector, options) const docLink = useDocLink() const ref = useRef(null) + const nameInputId = useId() const isExternal = currentDataset.provider === 'external' const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) const [loading, setLoading] = useState(false) @@ -232,13 +233,14 @@ const SettingsModal: FC = ({
-
+
+
handleValueChange('name', e.target.value)} + onValueChange={(value) => handleValueChange('name', value)} className="block h-9" placeholder={t(($) => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''} /> diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 79c1f082294..11888a09817 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -650,7 +650,7 @@ function render( return renderWithConsoleQuery(ui, { queryClient }) } -let appPermissionKeys: string[] = [AppACLPermission.AccessPoint, AppACLPermission.Deploy] +let appPermissionKeys: string[] = [AppACLPermission.Deploy] let appDetailAvailable = true const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], @@ -744,7 +744,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ describe('AppDeploy', () => { beforeEach(() => { vi.clearAllMocks() - appPermissionKeys = [AppACLPermission.AccessPoint, AppACLPermission.Deploy] + appPermissionKeys = [AppACLPermission.Deploy] appDetailAvailable = true mockBuiltInEnvironment.appDetail.enable_api = false mockBuiltInEnvironment.appDetail.enable_site = true @@ -815,18 +815,6 @@ describe('AppDeploy', () => { ).toHaveAttribute('href', '/app/app-1/access-point?environment=canary&accessPoint=serviceApi') }) - it('keeps active access points non-navigable without access point permission', () => { - appPermissionKeys = [AppACLPermission.Deploy] - - render() - - const canaryRow = within(screen.getByRole('row', { name: /Canary/ })) - const webAppLabel = - 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService' - expect(canaryRow.queryByRole('link', { name: webAppLabel })).not.toBeInTheDocument() - expect(canaryRow.getByRole('button', { name: webAppLabel })).toBeDisabled() - }) - it('renders the built-in version, access points, and publisher from live app data', () => { render() diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx index 607250e7ce6..d04dce259a8 100644 --- a/web/app/components/app/deploy/built-in-environment-card/index.tsx +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -19,7 +19,7 @@ function Divider() { return
} -export function BuiltInEnvironmentCard({ canAccessPoint = false }: { canAccessPoint?: boolean }) { +export function BuiltInEnvironmentCard() { const { t } = useTranslation('deployments') const { formatTime } = useTimestamp() const appDetail = useAppStore((state) => state.appDetail) @@ -90,9 +90,7 @@ export function BuiltInEnvironmentCard({ canAccessPoint = false }: { canAccessPo key={accessPoint} accessPoint={accessPoint} active={activeAccessPoints[accessPoint]} - href={ - canAccessPoint ? getAccessPointHref(appId, 'built-in', accessPoint) : undefined - } + href={getAccessPointHref(appId, 'built-in', accessPoint)} /> ))}
diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx index cc9b9451e59..cbee27225ed 100644 --- a/web/app/components/app/deploy/environment-table/index.tsx +++ b/web/app/components/app/deploy/environment-table/index.tsx @@ -23,7 +23,6 @@ import { EnvironmentRow } from './row' type EnvironmentTableProps = { appId: string - canAccessPoint?: boolean onChangeVersion?: (deployment: EnvironmentDeployment) => void onDeployLatest?: (deployment: EnvironmentDeployment) => void onDeployToEnvironment?: (environment: AppEnvironment) => void @@ -33,7 +32,6 @@ type EnvironmentTableProps = { export function EnvironmentTable({ appId, - canAccessPoint = false, onChangeVersion, onDeployLatest, onDeployToEnvironment, @@ -134,7 +132,6 @@ export function EnvironmentTable({ void onDeployLatest?: (deployment: EnvironmentDeployment) => void @@ -62,11 +60,7 @@ export function EnvironmentRow({ key={accessPoint} accessPoint={accessPoint} active={isAccessPointActive(accessPoint)} - href={ - canAccessPoint - ? getAccessPointHref(appId, row.environment.id, accessPoint) - : undefined - } + href={getAccessPointHref(appId, row.environment.id, accessPoint)} /> ))}
diff --git a/web/app/components/app/deploy/index.tsx b/web/app/components/app/deploy/index.tsx index 4f1bee0ed1e..c22034584dd 100644 --- a/web/app/components/app/deploy/index.tsx +++ b/web/app/components/app/deploy/index.tsx @@ -22,7 +22,7 @@ import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './use-refresh-a import { useUndeployWorkflow } from './use-undeploy-workflow' import { toDeploymentVersion } from './version' -function AppDeployContent({ appId, canAccessPoint }: { appId: string; canAccessPoint: boolean }) { +function AppDeployContent({ appId }: { appId: string }) { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation('common') const { t: tWorkflow } = useTranslation('workflow') @@ -86,10 +86,9 @@ function AppDeployContent({ appId, canAccessPoint }: { appId: string; canAccessP
- + setDeploymentRequest({ environment: environment.display_name, @@ -140,17 +139,17 @@ export default function AppDeploy() { if (!appDetail) return - const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { + const canDeploy = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, - }) + }).canDeploy - if (appDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy) return null + if (appDetail.mode !== AppModeEnum.WORKFLOW || !canDeploy) return null return ( - + ) } diff --git a/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx b/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx deleted file mode 100644 index 80d18eaaee6..00000000000 --- a/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { screen } from '@testing-library/react' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' -import { AccessPointIcon } from '../access-point-icon' - -describe('AccessPointIcon', () => { - it('links active access points when navigation is allowed', () => { - render( - , - ) - - expect(screen.getByRole('link')).toHaveAttribute( - 'href', - '/app/app-1/access-point?environment=built-in&accessPoint=webApp', - ) - }) - - it('keeps active access points visually active when navigation is not allowed', () => { - render() - - expect(screen.queryByRole('link')).not.toBeInTheDocument() - expect(screen.getByRole('button')).toBeDisabled() - expect(screen.getByRole('button')).not.toHaveClass('opacity-30') - }) - - it('dims inactive access points', () => { - render() - - expect(screen.getByRole('button')).toBeDisabled() - expect(screen.getByRole('button')).toHaveClass('opacity-30') - }) -}) diff --git a/web/app/components/app/deploy/shared/access-point-icon.tsx b/web/app/components/app/deploy/shared/access-point-icon.tsx index 253429f03fc..734e9b694ee 100644 --- a/web/app/components/app/deploy/shared/access-point-icon.tsx +++ b/web/app/components/app/deploy/shared/access-point-icon.tsx @@ -32,7 +32,7 @@ export function AccessPointIcon({ }: { active: boolean accessPoint: AccessPoint - href?: string + href: string }) { const { t } = useTranslation('agentV2') const labels = useAccessPointLabels() @@ -40,11 +40,9 @@ export function AccessPointIcon({ ? t(($) => $['agentDetail.access.status.inService']) : t(($) => $['agentDetail.access.status.outOfService']) const label = `${labels[accessPoint]} · ${status}` - const canNavigate = active && Boolean(href) const triggerClassName = cn( 'flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-regular text-text-secondary shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', - active && (canNavigate ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-default'), - !active && 'cursor-not-allowed opacity-30', + active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-30', ) const icon = ( @@ -54,7 +52,7 @@ export function AccessPointIcon({ {icon} diff --git a/web/app/components/app/deploy/shared/deployment-status.tsx b/web/app/components/app/deploy/shared/deployment-status.tsx index de7cac3dc53..4f4aabd2076 100644 --- a/web/app/components/app/deploy/shared/deployment-status.tsx +++ b/web/app/components/app/deploy/shared/deployment-status.tsx @@ -68,7 +68,9 @@ export function DeploymentStatus({ status }: { status?: DeploymentStatusValue }) ) : ( )} - {label} + + {label} + ) } diff --git a/web/app/components/app/overview/__tests__/app-chart.spec.tsx b/web/app/components/app/overview/__tests__/app-chart.spec.tsx index aa0e503ff09..07b0b666996 100644 --- a/web/app/components/app/overview/__tests__/app-chart.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-chart.spec.tsx @@ -40,7 +40,7 @@ describe('app-chart', () => { />, ) - expect(screen.getByText('Cost title'))!.toBeInTheDocument() + expect(screen.getByText('Cost title')).toHaveAttribute('title', 'Cost title') expect(screen.getByText('300'))!.toBeInTheDocument() expect(screen.queryByText('Last 7 days'))!.not.toBeInTheDocument() expect(screen.getByText(/\$3\.7500/))!.toBeInTheDocument() diff --git a/web/app/components/app/overview/app-chart.tsx b/web/app/components/app/overview/app-chart.tsx index 7596c7140ec..72f881a71c3 100644 --- a/web/app/components/app/overview/app-chart.tsx +++ b/web/app/components/app/overview/app-chart.tsx @@ -96,7 +96,10 @@ const Chart: React.FC = ({ >
-
+
{title}
{explanation && ( diff --git a/web/app/components/base/infotip/index.tsx b/web/app/components/base/infotip/index.tsx index 4e047d0e7e1..ecc3cf410e0 100644 --- a/web/app/components/base/infotip/index.tsx +++ b/web/app/components/base/infotip/index.tsx @@ -7,6 +7,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop const iconClassNames = { question: 'i-ri-question-line', information: 'i-ri-information-line', + warning: 'i-ri-error-warning-line', } as const const iconSizeClassNames = { diff --git a/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx index ed703ae844d..09da808d1c1 100644 --- a/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { vi } from 'vitest' +import { vi } from 'vite-plus/test' import { Img } from '..' vi.mock('@/app/components/base/image-gallery', () => ({ diff --git a/web/app/components/datasets/external-api/external-api-modal/index.tsx b/web/app/components/datasets/external-api/external-api-modal/index.tsx index 0e88c662c49..f9d9ad9b655 100644 --- a/web/app/components/datasets/external-api/external-api-modal/index.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/index.tsx @@ -266,7 +266,10 @@ const AddExternalAPIModal: FC = ({ >
- + Warning diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx index 3813778233a..c14ebf3fcdb 100644 --- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx @@ -1,12 +1,12 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Input } from '@langgenius/dify-ui/input' import { RiAddLine } from '@remixicon/react' import { useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useEffect, useId, useState } from 'react' import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' import { useModalContext } from '@/context/modal-context' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' @@ -30,6 +30,7 @@ const ExternalApiSelection: React.FC = ({ consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} }) const { data } = useQuery(externalKnowledgeApiQueryOptions) const externalKnowledgeApiList = data?.data ?? [] + const externalKnowledgeIdInputId = useId() const [selectedApiId, setSelectedApiId] = useState(external_knowledge_api_id) const { setShowExternalKnowledgeAPIModal } = useModalContext() @@ -94,14 +95,18 @@ const ExternalApiSelection: React.FC = ({
-
- onChange({ external_knowledge_id: e.target.value, external_knowledge_api_id }) + onValueChange={(value) => + onChange({ external_knowledge_id: value, external_knowledge_api_id }) } placeholder={t(($) => $.externalKnowledgeIdPlaceholder, { ns: 'dataset' }) ?? ''} /> diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx index 5b3240501dd..caabcdf7f57 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -112,7 +112,7 @@ describe('ExternalApiSelection', () => { } render() - await user.type(screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder'), 'kb-123') + await user.type(screen.getByRole('textbox', { name: 'dataset.externalKnowledgeId' }), 'kb-123') expect(onChange).toHaveBeenLastCalledWith( expect.objectContaining({ external_knowledge_id: 'kb-123' }), diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx index 764b51cb52c..7c4b00da20e 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx @@ -19,7 +19,6 @@ const expectedAppACLPermissionKeys = [ 'app.acl.tracing_config', 'app.acl.log_and_annotation', 'app.acl.access_config', - 'app.acl.access_point_manage', ] const getPermissionKeyMatcher = (permissionKey: string) => diff --git a/web/app/components/plugins/install-plugin/base/installed.tsx b/web/app/components/plugins/install-plugin/base/installed.tsx index 1da5b0b1e0b..34f42fcf6db 100644 --- a/web/app/components/plugins/install-plugin/base/installed.tsx +++ b/web/app/components/plugins/install-plugin/base/installed.tsx @@ -80,8 +80,13 @@ const Installed: FC = ({ } return ( <> -
-

+

+

{isFailed && errMsg ? ( errMsg ) : categoryTarget ? ( @@ -125,7 +130,7 @@ const Installed: FC = ({ )}

{/* Action Buttons */} -
+
{categoryTarget ? ( = ({ @@ -201,7 +201,9 @@ const InstallFromGitHub: React.FC = ({
-
{getTitle()}
+ + {getTitle()} +
{![ InstallStepFromGitHub.uploadFailed, diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx index 7fb5d9c3239..ababf3dfb69 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx @@ -419,6 +419,9 @@ describe('InstallFromLocalPackage', () => { await waitFor(() => { expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() expect(screen.getByTestId('package-step')).toHaveTextContent('uploadFailed') + expect( + screen.getByRole('dialog', { name: 'plugin.installModal.uploadFailed' }), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx index f669cc7bfa9..825484b0d4b 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { Dependency, PluginCategoryEnum, PluginDeclaration } from '../../types' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogClose, DialogContent } from '@langgenius/dify-ui/dialog' +import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' import * as React from 'react' import { useCallback, useState } from 'react' @@ -90,10 +90,10 @@ const InstallFromLocalPackage: React.FC = ({ @@ -109,8 +109,10 @@ const InstallFromLocalPackage: React.FC = ({ } /> -
-
{getTitle()}
+
+ + {getTitle()} +
{step === InstallStep.uploading && ( = ({ -
+
{getTitle()} diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx index 36354d807ed..edc6ccfd410 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx @@ -184,8 +184,7 @@ describe('Item Component', () => { ) const enterRenameMode = () => { - const firstButton = result.container.querySelectorAll('button')[0] as HTMLElement - fireEvent.click(firstButton) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.rename' })) } return { ...result, onRename, enterRenameMode } @@ -196,7 +195,7 @@ describe('Item Component', () => { enterRenameMode() - expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'common.operation.rename' })).toBeInTheDocument() }) it('should show save and cancel buttons in rename mode', () => { @@ -213,7 +212,7 @@ describe('Item Component', () => { enterRenameMode() - const input = screen.getByRole('textbox') + const input = screen.getByRole('textbox', { name: 'common.operation.rename' }) fireEvent.change(input, { target: { value: 'New Name' } }) fireEvent.click(screen.getByText('common.operation.save')) @@ -228,7 +227,7 @@ describe('Item Component', () => { const { enterRenameMode } = renderWithRenameEnabled() enterRenameMode() - expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'common.operation.rename' })).toBeInTheDocument() fireEvent.click(screen.getByText('common.operation.cancel')) @@ -240,7 +239,7 @@ describe('Item Component', () => { enterRenameMode() - const input = screen.getByRole('textbox') + const input = screen.getByRole('textbox', { name: 'common.operation.rename' }) fireEvent.change(input, { target: { value: 'Updated Value' } }) expect(input).toHaveValue('Updated Value') diff --git a/web/app/components/plugins/plugin-auth/authorized/item.tsx b/web/app/components/plugins/plugin-auth/authorized/item.tsx index 76da4b84066..7bef9a46149 100644 --- a/web/app/components/plugins/plugin-auth/authorized/item.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/item.tsx @@ -2,6 +2,7 @@ import type { Credential } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Input } from '@langgenius/dify-ui/input' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiInformationLine } from '@remixicon/react' @@ -9,7 +10,6 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import Input from '@/app/components/base/input' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { CredentialTypeEnum } from '../types' @@ -85,10 +85,10 @@ const Item = ({ {renaming && (
$['operation.rename'], { ns: 'common' })} + className="h-6 grow" value={renameValue} - onChange={(e) => setRenameValue(e.target.value)} + onValueChange={setRenameValue} placeholder={t(($) => $['placeholder.input'], { ns: 'common' })} onClick={(e) => e.stopPropagation()} /> diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index 0e873ff1990..2c2b6d6077e 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -119,7 +119,10 @@ const Conversion = () => { >
- + {confirmTitle} diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index a1e5ef098a1..210dd6c8770 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -2,7 +2,7 @@ import type { DatasourceProviderType, ToolProviderType, } from '@dify/contracts/api/console/workspaces/types.gen' -import type { VarType } from '../workflow/types' +import type { Variable, VarType } from '../workflow/types' type LocalizedText = { en_US: T @@ -212,10 +212,21 @@ export type WorkflowToolProviderParameter = { type?: string } +export type WorkflowToolOutputSource = { + nodeId: string + nodeTitle: string + outputIndex: number +} + +export type WorkflowToolOutputVariable = Variable & { + source?: WorkflowToolOutputSource +} + export type WorkflowToolProviderOutputParameter = { name: string description: string type?: VarType + source?: WorkflowToolOutputSource reserved?: boolean } diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx index 5ce7f7bfff9..96d372a0b21 100644 --- a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { WorkflowToolDrawerPayload } from '../index' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { WorkflowToolDrawer } from '../index' @@ -112,6 +112,7 @@ describe('WorkflowToolDrawer', () => { labels: ['label1', 'new-label'], }), ) + expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('outputParameters') }) it('should block invalid tool-call names before saving', async () => { @@ -158,11 +159,165 @@ describe('WorkflowToolDrawer', () => { }) }) - it('should show duplicate reserved output warnings', () => { + it('should not show output warnings when names are valid and unique', () => { + render( + , + ) + + expect( + screen.queryByRole('button', { + name: /reservedParameterDuplicateTip|duplicateOutputVariable/, + }), + ).not.toBeInTheDocument() + }) + + it('should show one reserved-name warning on the user output only', () => { render( , ) - expect(screen.getAllByTestId('reserved-output-warning').length).toBeGreaterThan(0) + const userOutputRow = screen.getByRole('row', { name: /text.*Reserved output duplicate/ }) + expect( + within(userOutputRow).getByRole('button', { + name: 'tools.createTool.toolOutput.reservedParameterDuplicateTip', + }), + ).toBeInTheDocument() + + const reservedOutputRow = screen.getByRole('row', { + name: /text.*tools\.createTool\.toolOutput\.reserved.*string/, + }) + expect(within(reservedOutputRow).queryByRole('button')).not.toBeInTheDocument() + }) + + it('should identify the End node sources for duplicate outputs', async () => { + const user = userEvent.setup() + const outputParameters: WorkflowToolDrawerPayload['outputParameters'] = [ + { + name: 'result', + description: 'Success output', + source: { nodeId: 'end-success', nodeTitle: 'Success End', outputIndex: 0 }, + }, + { + name: 'result', + description: 'Fallback output', + source: { nodeId: 'end-fallback', nodeTitle: 'Fallback End', outputIndex: 0 }, + }, + ] + + render( + , + ) + + expect( + screen.getByRole('row', { name: /result.*Success End.*Success output/ }), + ).toBeInTheDocument() + expect( + screen.getByRole('row', { name: /result.*Fallback End.*Fallback output/ }), + ).toBeInTheDocument() + + const duplicateWarnings = screen.getAllByRole('button', { + name: /workflow\.errorMsg\.duplicateOutputVariable/, + }) + expect(duplicateWarnings).toHaveLength(2) + + await user.click(duplicateWarnings[0]!) + + const duplicateDetails = (await screen.findAllByRole('dialog')).find((dialog) => + within(dialog).queryByText(/workflow\.errorMsg\.duplicateOutputVariable/), + )! + expect(duplicateDetails).toHaveTextContent('Success End') + expect(duplicateDetails).toHaveTextContent('Fallback End') + }) + + it('should combine reserved-name and duplicate-name issues into one warning per user output', async () => { + const user = userEvent.setup() + const outputParameters: WorkflowToolDrawerPayload['outputParameters'] = [ + { + name: 'text', + description: 'Success output', + source: { nodeId: 'end-success', nodeTitle: 'Output', outputIndex: 0 }, + }, + { + name: 'text', + description: 'Fallback output', + source: { nodeId: 'end-fallback', nodeTitle: 'Output', outputIndex: 0 }, + }, + ] + + render( + , + ) + + const issueWarnings = screen.getAllByRole('button', { + name: /tools\.createTool\.toolOutput\.reservedParameterDuplicateTip.*workflow\.errorMsg\.duplicateOutputVariable/, + }) + expect(issueWarnings).toHaveLength(2) + + await user.click(issueWarnings[0]!) + + const issueDetails = (await screen.findAllByRole('dialog')).find((dialog) => + within(dialog).queryByText('tools.createTool.toolOutput.reservedParameterDuplicateTip'), + )! + expect(issueDetails).toHaveTextContent( + 'tools.createTool.toolOutput.reservedParameterDuplicateTip', + ) + expect(issueDetails).toHaveTextContent('workflow.errorMsg.duplicateOutputVariable') + expect(issueDetails).toHaveTextContent('Output (1/2)') + expect(issueDetails).toHaveTextContent('Output (2/2)') + expect(issueDetails).not.toHaveTextContent('end-succ') + expect(issueDetails).not.toHaveTextContent('end-fall') + + expect(screen.getByRole('row', { name: /Output \(1\/2\).*Success output/ })).toBeInTheDocument() + expect( + screen.getByRole('row', { name: /Output \(2\/2\).*Fallback output/ }), + ).toBeInTheDocument() + + const reservedOutputRow = screen.getByRole('row', { + name: /text.*tools\.createTool\.toolOutput\.reserved.*string/, + }) + expect(within(reservedOutputRow).queryByRole('button')).not.toBeInTheDocument() + }) + + it('should keep schema-derived outputs compact when source metadata is unavailable', () => { + render( + , + ) + + expect(screen.getByRole('row', { name: /answer string Published answer/ })).toBeInTheDocument() + expect(screen.queryByText('tools.createTool.toolOutput.sourceNode')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/tools/workflow-tool/__tests__/utils.test.ts b/web/app/components/tools/workflow-tool/__tests__/utils.test.ts index 4fe0f1917e4..c6593c16202 100644 --- a/web/app/components/tools/workflow-tool/__tests__/utils.test.ts +++ b/web/app/components/tools/workflow-tool/__tests__/utils.test.ts @@ -1,9 +1,67 @@ import type { + WorkflowToolOutputSource, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, } from '../../types' import { VarType } from '@/app/components/workflow/types' -import { buildWorkflowOutputParameters } from '../utils' +import { + buildWorkflowOutputParameters, + getDuplicateWorkflowOutputGroups, + getSourceNodeDisplayName, + getUniqueWorkflowOutputSources, +} from '../utils' + +describe('workflow output sources', () => { + const duplicateSources: WorkflowToolOutputSource[] = [ + { nodeId: 'end-success', nodeTitle: 'Output', outputIndex: 0 }, + { nodeId: 'end-fallback', nodeTitle: 'Output', outputIndex: 0 }, + ] + + it('deduplicates source nodes while preserving their order', () => { + const outputs: WorkflowToolProviderOutputParameter[] = [ + { name: 'result', description: '', source: duplicateSources[0] }, + { name: 'result', description: '', source: duplicateSources[0] }, + { name: 'result', description: '', source: duplicateSources[1] }, + ] + + expect(getUniqueWorkflowOutputSources(outputs)).toEqual(duplicateSources) + }) + + it('numbers sources with the same title without exposing their node IDs', () => { + expect(getSourceNodeDisplayName(duplicateSources[0]!, duplicateSources)).toBe('Output (1/2)') + expect(getSourceNodeDisplayName(duplicateSources[1]!, duplicateSources)).toBe('Output (2/2)') + }) +}) + +describe('getDuplicateWorkflowOutputGroups', () => { + it('groups trimmed duplicate names while preserving their End node sources', () => { + const params: WorkflowToolProviderOutputParameter[] = [ + { + name: ' result ', + description: 'Success output', + source: { nodeId: 'end-success', nodeTitle: 'Success End', outputIndex: 0 }, + }, + { + name: 'result', + description: 'Fallback output', + source: { nodeId: 'end-fallback', nodeTitle: 'Fallback End', outputIndex: 0 }, + }, + { + name: 'unique', + description: 'Unique output', + source: { nodeId: 'end-unique', nodeTitle: 'Unique End', outputIndex: 0 }, + }, + ] + + const result = getDuplicateWorkflowOutputGroups(params) + + expect([...result.keys()]).toEqual(['result']) + expect(result.get('result')?.map((item) => item.source?.nodeTitle)).toEqual([ + 'Success End', + 'Fallback End', + ]) + }) +}) describe('buildWorkflowOutputParameters', () => { it('returns provided output parameters when array input exists', () => { @@ -13,7 +71,7 @@ describe('buildWorkflowOutputParameters', () => { const result = buildWorkflowOutputParameters(params, null) - expect(result).toBe(params) + expect(result).toEqual(params) }) it('fills missing output description and type from schema when array input exists', () => { diff --git a/web/app/components/tools/workflow-tool/helpers.ts b/web/app/components/tools/workflow-tool/helpers.ts index 7ecc8f689d0..02057150686 100644 --- a/web/app/components/tools/workflow-tool/helpers.ts +++ b/web/app/components/tools/workflow-tool/helpers.ts @@ -56,7 +56,7 @@ export const hasReservedWorkflowOutputConflict = ( } export const getWorkflowOutputParameters = ( - rawOutputParameters: WorkflowToolProviderOutputParameter[], + rawOutputParameters: WorkflowToolProviderOutputParameter[] | undefined, outputSchema?: WorkflowToolProviderOutputSchema, ) => { return buildWorkflowOutputParameters(rawOutputParameters, outputSchema) diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index 0f8720f10be..d5d70c448f4 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -1,8 +1,9 @@ import type { + WorkflowToolOutputVariable, WorkflowToolProviderRequest, WorkflowToolProviderResponse, } from '@/app/components/tools/types' -import type { InputVar, Variable } from '@/app/components/workflow/types' +import type { InputVar } from '@/app/components/workflow/types' import { act, renderHook } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import { isParametersOutdated, useConfigureButton } from '../use-configure-button' @@ -46,12 +47,15 @@ const createMockInputVar = (overrides: Partial = {}): InputVar => ...overrides, }) as InputVar -const createMockVariable = (overrides: Partial = {}): Variable => +const createMockVariable = ( + overrides: Partial = {}, +): WorkflowToolOutputVariable => ({ variable: 'output_var', value_type: 'string', + source: { nodeId: 'end-1', nodeTitle: 'Success End', outputIndex: 0 }, ...overrides, - }) as Variable + }) as WorkflowToolOutputVariable const createMockDetail = ( overrides: Partial = {}, @@ -293,6 +297,10 @@ describe('useConfigureButton', () => { form: 'llm', description: '', }) + expect(result.current.payload.outputParameters[0]).toMatchObject({ + name: 'output_var', + source: { nodeId: 'end-1', nodeTitle: 'Success End', outputIndex: 0 }, + }) }) it('should use detail values when published with detail', () => { @@ -370,6 +378,46 @@ describe('useConfigureButton', () => { // Mutation handlers describe('handleCreate', () => { + it('should publish before creating the provider', async () => { + mockCreateWorkflowToolProvider.mockResolvedValue({}) + const handlePublish = vi.fn().mockResolvedValue(undefined) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ handlePublish })), + ) + + await act(async () => { + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) + }) + + expect(handlePublish).toHaveBeenCalledOnce() + expect(mockCreateWorkflowToolProvider).toHaveBeenCalledOnce() + expect(handlePublish.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateWorkflowToolProvider.mock.invocationCallOrder[0]!, + ) + }) + + it('should not create the provider when publishing fails', async () => { + const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed')) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ handlePublish })), + ) + + await act(async () => { + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) + }) + + expect(mockCreateWorkflowToolProvider).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Publish failed' }) + }) + it('should create provider, invalidate caches, refresh, and notify configured', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const onRefreshData = vi.fn() @@ -439,6 +487,7 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') + expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) expect(onConfigured).toHaveBeenCalled() }) diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 6c8e982c624..3fa0e39e3e4 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -1,11 +1,12 @@ import type { Emoji, + WorkflowToolOutputVariable, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse, } from '@/app/components/tools/types' -import type { InputVar, Variable } from '@/app/components/workflow/types' +import type { InputVar } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useMemo, useRef } from 'react' @@ -67,16 +68,19 @@ function buildExistingParameters( }) } -function buildNewOutputParameters(outputs?: Variable[]): WorkflowToolProviderOutputParameter[] { +function buildNewOutputParameters( + outputs?: WorkflowToolOutputVariable[], +): WorkflowToolProviderOutputParameter[] { return (outputs || []).map((item) => ({ name: item.variable, description: '', type: item.value_type, + source: item.source, })) } function buildExistingOutputParameters( - outputs: Variable[] | undefined, + outputs: WorkflowToolOutputVariable[] | undefined, detail: WorkflowToolProviderResponse, ): WorkflowToolProviderOutputParameter[] { return (outputs || []).map((item) => { @@ -85,6 +89,7 @@ function buildExistingOutputParameters( name: item.variable, description: found ? found.description : '', type: item.value_type, + source: item.source, } }) } @@ -100,7 +105,7 @@ type UseConfigureButtonOptions = { name: string description: string inputs?: InputVar[] - outputs?: Variable[] + outputs?: WorkflowToolOutputVariable[] handlePublish: (params?: PublishWorkflowParams) => Promise onRefreshData?: () => void onConfigured?: () => void @@ -121,7 +126,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData, onConfigured, } = options - const { t } = useTranslation() // Data fetching via React Query @@ -180,6 +184,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { // Mutation handlers (not memoized — only used in conditionally-rendered modal) const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { try { + await handlePublish() await createWorkflowToolProvider(data) invalidateAllWorkflowTools() onRefreshData?.() @@ -204,6 +209,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateAllWorkflowTools() invalidateDetail(workflowAppId) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onConfigured?.() } catch (e) { toast.error((e as Error).message) diff --git a/web/app/components/tools/workflow-tool/index.stories.tsx b/web/app/components/tools/workflow-tool/index.stories.tsx new file mode 100644 index 00000000000..bf418e253ac --- /dev/null +++ b/web/app/components/tools/workflow-tool/index.stories.tsx @@ -0,0 +1,132 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { WorkflowToolDrawerPayload } from '.' +import { fn } from 'storybook/test' +import { VarType } from '@/app/components/workflow/types' +import { WorkflowToolDrawer } from '.' + +const payload = { + icon: { + content: '🔀', + background: '#EEF4FF', + }, + label: 'Branching Workflow', + name: 'branching_workflow', + description: 'Returns different outputs depending on the executed branch.', + parameters: [ + { + name: 'route', + description: 'Selects the branch to execute.', + form: 'llm', + required: true, + type: 'string', + }, + ], + outputParameters: [ + { + name: 'aaa', + description: 'Output from the first End node.', + type: VarType.string, + source: { nodeId: 'end-success', nodeTitle: 'Success End', outputIndex: 0 }, + }, + { + name: 'bbb', + description: 'Output from the second End node.', + type: VarType.number, + source: { nodeId: 'end-fallback', nodeTitle: 'Fallback End', outputIndex: 0 }, + }, + ], + labels: [], + privacy_policy: '', + workflow_app_id: 'workflow-app-story', +} satisfies WorkflowToolDrawerPayload + +const meta = { + title: 'Tools/WorkflowTool/Drawer', + component: WorkflowToolDrawer, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Configures a published workflow as a tool. This story covers the union of outputs from multiple End nodes.', + }, + }, + }, + args: { + isAdd: true, + payload, + onCreate: fn(), + onHide: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const MultipleEndOutputs: Story = {} + +export const PublishedOutputsWithoutDraftSources: Story = { + args: { + isAdd: false, + payload: { + ...payload, + outputParameters: undefined, + tool: { + output_schema: { + type: 'object', + properties: { + answer: { + type: VarType.string, + description: 'Output loaded from the published tool schema.', + }, + }, + }, + }, + workflow_tool_id: 'workflow-tool-story', + }, + }, +} + +export const DuplicateEndOutputs: Story = { + args: { + payload: { + ...payload, + outputParameters: [ + { + name: 'result', + description: 'Output from the successful branch.', + type: VarType.string, + source: { nodeId: 'end-success', nodeTitle: 'Success End', outputIndex: 0 }, + }, + { + name: 'result', + description: 'Output from the fallback branch.', + type: VarType.number, + source: { nodeId: 'end-fallback', nodeTitle: 'Fallback End', outputIndex: 0 }, + }, + ], + }, + }, +} + +export const DuplicateReservedEndOutputs: Story = { + args: { + payload: { + ...payload, + outputParameters: [ + { + name: 'text', + description: 'Output from the successful branch.', + type: VarType.string, + source: { nodeId: 'end-success', nodeTitle: 'Output', outputIndex: 0 }, + }, + { + name: 'text', + description: 'Output from the fallback branch.', + type: VarType.number, + source: { nodeId: 'end-fallback', nodeTitle: 'Output', outputIndex: 0 }, + }, + ], + }, + }, +} diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index c2da9f97a29..3272dcf28c3 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -2,6 +2,7 @@ import type { DrawerProps } from '@langgenius/dify-ui/drawer' import type { Emoji, + WorkflowToolOutputSource, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, @@ -21,7 +22,6 @@ import { } from '@langgenius/dify-ui/drawer' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { produce } from 'immer' import * as React from 'react' import { useMemo, useState } from 'react' @@ -33,6 +33,7 @@ import Input from '@/app/components/base/input' import LabelSelector from '@/app/components/tools/labels/selector' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' +import { normalizeWorkflowOutputName } from '@/app/components/workflow/utils/variable' import { buildWorkflowToolRequestPayload, getReservedWorkflowOutputParameters, @@ -40,6 +41,11 @@ import { hasReservedWorkflowOutputConflict, isWorkflowToolNameValid, } from './helpers' +import { + getDuplicateWorkflowOutputGroups, + getSourceNodeDisplayName, + getUniqueWorkflowOutputSources, +} from './utils' export type WorkflowToolDrawerPayload = { icon: Emoji @@ -47,7 +53,7 @@ export type WorkflowToolDrawerPayload = { name: string description: string parameters: WorkflowToolProviderParameter[] - outputParameters: WorkflowToolProviderOutputParameter[] + outputParameters?: WorkflowToolProviderOutputParameter[] labels: string[] privacy_policy: string tool?: { @@ -135,6 +141,88 @@ const WorkflowToolDrawerFrame = ({ ) } +const WorkflowToolOutputName = React.memo( + ({ + duplicateSources, + item, + reservedOutputParameters, + }: { + duplicateSources?: WorkflowToolOutputSource[] + item: WorkflowToolProviderOutputParameter + reservedOutputParameters: WorkflowToolProviderOutputParameter[] + }) => { + const { t } = useTranslation() + const reservedOutputDuplicateTip = t( + ($) => $['createTool.toolOutput.reservedParameterDuplicateTip'], + { ns: 'tools' }, + ) + const sourceNodeLabel = t(($) => $['createTool.toolOutput.sourceNode'], { ns: 'tools' }) + const duplicateOutputTip = t(($) => $['errorMsg.duplicateOutputVariable'], { + ns: 'workflow', + variable: normalizeWorkflowOutputName(item.name), + }) + const hasReservedNameConflict = + !item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name) + const hasDuplicateNameConflict = !item.reserved && !!duplicateSources + const issueLabel = hasReservedNameConflict + ? hasDuplicateNameConflict + ? `${reservedOutputDuplicateTip} ${duplicateOutputTip}` + : reservedOutputDuplicateTip + : duplicateOutputTip + const sources = duplicateSources || [] + + return ( +
+
+ {item.name} + {item.reserved && ( + + {t(($) => $['createTool.toolOutput.reserved'], { ns: 'tools' })} + + )} + {hasReservedNameConflict || hasDuplicateNameConflict ? ( + +
+ {hasReservedNameConflict ?

{reservedOutputDuplicateTip}

: null} + {hasDuplicateNameConflict ? ( +
+

{duplicateOutputTip}

+ {sources.length > 0 ? ( +
    + {sources.map((source) => { + const sourceTitle = getSourceNodeDisplayName(source, sources) + return ( +
  • + {sourceNodeLabel}: {sourceTitle} +
  • + ) + })} +
+ ) : null} +
+ ) : null} +
+
+ ) : null} +
+
{item.type}
+ {hasDuplicateNameConflict && item.source ? ( +
+ {sourceNodeLabel} ·{' '} + {getSourceNodeDisplayName(item.source, sources)} +
+ ) : null} +
+ ) + }, +) + export function WorkflowToolDrawer({ isAdd, payload, @@ -158,6 +246,15 @@ export function WorkflowToolDrawer({ [rawOutputParameters, outputSchema], ) const reservedOutputParameters = useMemo(() => getReservedWorkflowOutputParameters(t), [t]) + const duplicateOutputSourceGroups = useMemo(() => { + const groups = getDuplicateWorkflowOutputGroups(outputParameters) + const sourceGroups = new Map() + + for (const [name, outputs] of groups) + sourceGroups.set(name, getUniqueWorkflowOutputSources(outputs)) + + return sourceGroups + }, [outputParameters]) const handleParameterChange = (key: string, value: string, index: number) => { const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => { @@ -392,46 +489,24 @@ export function WorkflowToolDrawer({ {[...reservedOutputParameters, ...outputParameters].map((item, index) => ( - + -
-
- - {item.name} - - - {item.reserved - ? t(($) => $['createTool.toolOutput.reserved'], { ns: 'tools' }) - : ''} - - {!item.reserved && - hasReservedWorkflowOutputConflict( - reservedOutputParameters, - item.name, - ) ? ( - - - } - /> - -
- {t( - ($) => - $['createTool.toolOutput.reservedParameterDuplicateTip'], - { ns: 'tools' }, - )} -
-
-
- ) : null} -
-
{item.type}
-
+ diff --git a/web/app/components/tools/workflow-tool/utils.ts b/web/app/components/tools/workflow-tool/utils.ts index 5273535bab5..a4a1da8dd69 100644 --- a/web/app/components/tools/workflow-tool/utils.ts +++ b/web/app/components/tools/workflow-tool/utils.ts @@ -1,8 +1,10 @@ import type { + WorkflowToolOutputSource, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, } from '../types' import { VarType } from '@/app/components/workflow/types' +import { normalizeWorkflowOutputName } from '@/app/components/workflow/utils/variable' const validVarTypes = new Set(Object.values(VarType)) @@ -12,6 +14,62 @@ const normalizeVarType = (type?: string): VarType | undefined => { return validVarTypes.has(type) ? (type as VarType) : undefined } +export const getUniqueWorkflowOutputSources = ( + outputParameters: readonly WorkflowToolProviderOutputParameter[], +): WorkflowToolOutputSource[] => { + const sources: WorkflowToolOutputSource[] = [] + const sourceNodeIds = new Set() + + for (const output of outputParameters) { + const source = output.source + if (!source || sourceNodeIds.has(source.nodeId)) continue + + sourceNodeIds.add(source.nodeId) + sources.push(source) + } + + return sources +} + +export const getSourceNodeDisplayName = ( + source: WorkflowToolOutputSource, + duplicateSources: readonly WorkflowToolOutputSource[], +) => { + const title = source.nodeTitle || source.nodeId + if (!source.nodeTitle) return title + + let sameTitleSourceCount = 0 + let sourceIndex = -1 + + for (const candidate of duplicateSources) { + if (candidate.nodeTitle !== source.nodeTitle) continue + + if (candidate.nodeId === source.nodeId) sourceIndex = sameTitleSourceCount + sameTitleSourceCount += 1 + } + + if (sameTitleSourceCount < 2 || sourceIndex < 0) return title + + return `${title} (${sourceIndex + 1}/${sameTitleSourceCount})` +} + +export const getDuplicateWorkflowOutputGroups = ( + outputParameters: WorkflowToolProviderOutputParameter[], +) => { + const groups = new Map() + + for (const item of outputParameters) { + const name = normalizeWorkflowOutputName(item.name) + if (!name) continue + + const group = groups.get(name) || [] + group.push(item) + groups.set(name, group) + } + + return new Map([...groups].filter(([, items]) => items.length > 1)) +} + export const buildWorkflowOutputParameters = ( outputParameters: WorkflowToolProviderOutputParameter[] | null | undefined, outputSchema?: WorkflowToolProviderOutputSchema | null, diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index cafae7b2526..54ddf212a8f 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -149,6 +149,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({ data-start-node-limit-exceeded={String(Boolean(props.startNodeLimitExceeded))} data-has-trigger-node={String(Boolean(props.hasTriggerNode))} data-inputs={JSON.stringify(inputs)} + data-outputs={JSON.stringify(props.outputs ?? [])} > + + )}
- {!isEmptySearch && canEdit !== false && ( + {actions && (
+
+ ) +} + +function SkillGridPagination({ + state, +}: { + state: Extract< + Extract['content'], + { kind: 'list' } + >['pagination'] +}) { + const { t } = useTranslation('common') + + if (state.status === 'none') return null + if (state.status === 'error') return + + return ( +
+ {t(($) => $.loading)} + +
+ ) +} + +function SkillGrid({ state }: SkillGridProps) { + const { t } = useTranslation('skill') + const isBusy = + state.status === 'pending' || + (state.status === 'error' && state.isRetrying) || + (state.status === 'ready' && state.isFetching) + const readyContent = state.status === 'ready' ? state.content : undefined + + return ( +
$['skillManagement.listLabel'])} aria-busy={isBusy}> + {state.status === 'pending' && ( +
+ +
)} - {!isPending && !isError && skills.length === 0 && ( + {state.status === 'error' && ( $['skillManagement.loadingError'])} + /> + )} + {readyContent?.kind === 'empty' && ( + $['skillManagement.emptySearch']) : t(($) => $['skillManagement.empty']) } /> )} - {!isPending && - !isError && - skills.map((skill) => ( - - ))} - {!isPending && !isError && isFetchingNextPage && } + {readyContent?.kind === 'list' && ( + <> + {readyContent.refresh.status === 'error' && ( + + )} + {/* Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility */} + {/* oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers. */} +
    + {readyContent.skills.map((skill) => ( + + ))} +
+ + + )}
) } @@ -864,16 +959,91 @@ export default function SkillsPage() { const handleListScroll = (event: UIEvent) => { const target = event.currentTarget const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight - if (scrollBottom < 80 && hasNextPage && !isFetchingNextPage) void fetchNextPage() + if ( + scrollBottom < 80 && + hasNextPage && + !skillsQuery.isFetching && + !skillsQuery.isFetchNextPageError + ) + void fetchNextPage() } useEffect(() => { const viewport = listViewportRef.current - if (!viewport || viewport.clientHeight === 0 || isPending || isFetchingNextPage || !hasNextPage) + if ( + !viewport || + viewport.clientHeight === 0 || + isPending || + skillsQuery.isFetching || + skillsQuery.isFetchNextPageError || + !hasNextPage + ) return - if (viewport.scrollHeight - viewport.clientHeight < 80) void fetchNextPage() - }, [fetchNextPage, hasNextPage, isFetchingNextPage, isPending, skills.length]) + if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80) + void fetchNextPage() + }, [ + fetchNextPage, + hasNextPage, + isPending, + skills.length, + skillsQuery.isFetching, + skillsQuery.isFetchNextPageError, + ]) + + const isFiltered = !!debouncedKeyword || selectedTags.length > 0 + const skillGridState: SkillGridState = isPending + ? { status: 'pending' } + : skillsQuery.isLoadingError || (skills.length === 0 && skillsQuery.isRefetchError) + ? { + status: 'error', + isRetrying: skillsQuery.isFetching, + onRetry: () => void skillsQuery.refetch(), + } + : { + status: 'ready', + content: + skills.length === 0 + ? { + kind: 'empty', + emptyState: isFiltered ? 'filtered' : 'skills', + actions: + canEdit && !isFiltered + ? { + creating: createMutation.isPending, + importing: importMutation.isPending, + onCreate: handleCreate, + onImport: () => importInputRef.current?.click(), + } + : undefined, + } + : { + kind: 'list', + skills, + pagination: skillsQuery.isFetchNextPageError + ? { + status: 'error', + isRetrying: isFetchingNextPage, + onRetry: () => void fetchNextPage(), + } + : isFetchingNextPage + ? { status: 'loading' } + : { status: 'none' }, + refresh: skillsQuery.isRefetchError + ? { + status: 'error', + isRetrying: skillsQuery.isFetching, + onRetry: () => void skillsQuery.refetch(), + } + : { status: 'none' }, + cardActions: { + canDelete, + canEdit, + onOpenTagManagement: () => setShowTagManagementModal(true), + }, + }, + isFetching: skillsQuery.isFetching, + } return (
@@ -911,21 +1081,7 @@ export default function SkillsPage() { onScroll={handleListScroll} > - 0} - isError={skillsQuery.isError} - isFetching={skillsQuery.isFetching} - isFetchingNextPage={skillsQuery.isFetchingNextPage} - isPending={skillsQuery.isPending} - onCreate={handleCreate} - onImport={() => importInputRef.current?.click()} - onOpenTagManagement={() => setShowTagManagementModal(true)} - /> + diff --git a/web/features/tag-management/__tests__/skill-card-tags.spec.tsx b/web/features/tag-management/__tests__/skill-card-tags.spec.tsx index c9ca8b69ede..9e5528fb654 100644 --- a/web/features/tag-management/__tests__/skill-card-tags.spec.tsx +++ b/web/features/tag-management/__tests__/skill-card-tags.spec.tsx @@ -2,7 +2,7 @@ import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types. import type { ComponentProps } from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { SkillCardTags } from '../components/skill-card-tags' const mocks = vi.hoisted(() => ({ diff --git a/web/i18n-config/__tests__/plural-selector.spec.ts b/web/i18n-config/__tests__/plural-selector.spec.ts index 9e98128f67e..ceeed3aeaa4 100644 --- a/web/i18n-config/__tests__/plural-selector.spec.ts +++ b/web/i18n-config/__tests__/plural-selector.spec.ts @@ -1,6 +1,6 @@ import type { SelectorParam } from 'i18next' import { createInstance } from 'i18next' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import agentV2 from '../../i18n/en-US/agent-v-2.json' import skill from '../../i18n/en-US/skill.json' import { getInitOptions } from '../settings' diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json index 18321e3b376..a7f129455c5 100644 --- a/web/i18n/ar-TN/permission-keys.json +++ b/web/i18n/ar-TN/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "إدارة إعدادات امتداد API", "app.access_config": "تكوين أذونات الوصول إلى التطبيق", "app.acl.access_config": "عرض أذونات الوصول وإدارتها", - "app.acl.access_point_manage": "عرض نقاط الوصول وإدارتها", "app.acl.delete": "حذف التطبيق", "app.acl.deploy": "نشر التطبيق", "app.acl.edit": "تعديل معلومات التطبيق وتنسيقه", diff --git a/web/i18n/ar-TN/tools.json b/web/i18n/ar-TN/tools.json index 5f6bebe9d21..6a9ed92c7e1 100644 --- a/web/i18n/ar-TN/tools.json +++ b/web/i18n/ar-TN/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "الوصف", "createTool.toolOutput.reserved": "محجوز", "createTool.toolOutput.reservedParameterDuplicateTip": "text و json و files هي متغيرات محجوزة. لا يمكن أن تظهر المتغيرات بهذه الأسماء في مخطط الإخراج.", + "createTool.toolOutput.sourceNode": "العقدة المصدر", "createTool.toolOutput.title": "إخراج الأداة", "createTool.urlError": "يرجى إدخال عنوان URL صالح", "createTool.viewSchemaSpec": "عرض مواصفات OpenAPI-Swagger", diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json index 2d546c0e082..fb32d92c699 100644 --- a/web/i18n/de-DE/permission-keys.json +++ b/web/i18n/de-DE/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "API-Erweiterungskonfiguration verwalten", "app.access_config": "App-Zugriffsberechtigungen konfigurieren", "app.acl.access_config": "Zugriffsberechtigungen anzeigen und verwalten", - "app.acl.access_point_manage": "Zugangspunkte anzeigen und verwalten", "app.acl.delete": "App löschen", "app.acl.deploy": "App bereitstellen", "app.acl.edit": "App-Informationen bearbeiten und App orchestrieren", diff --git a/web/i18n/de-DE/tools.json b/web/i18n/de-DE/tools.json index f4dcfd034ca..05d8aebdef2 100644 --- a/web/i18n/de-DE/tools.json +++ b/web/i18n/de-DE/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Beschreibung", "createTool.toolOutput.reserved": "Reserviert", "createTool.toolOutput.reservedParameterDuplicateTip": "Text, JSON und Dateien sind reservierte Variablen. Variablen mit diesen Namen dürfen im Ausgabeschema nicht erscheinen.", + "createTool.toolOutput.sourceNode": "Quellknoten", "createTool.toolOutput.title": "Werkzeugausgabe", "createTool.urlError": "Bitte geben Sie eine gültige URL ein", "createTool.viewSchemaSpec": "Die OpenAPI-Swagger-Spezifikation anzeigen", diff --git a/web/i18n/en-US/permission-keys.json b/web/i18n/en-US/permission-keys.json index e69f68bb0b3..2344caa11e1 100644 --- a/web/i18n/en-US/permission-keys.json +++ b/web/i18n/en-US/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Manage API extension configuration", "app.access_config": "Configure app access permissions", "app.acl.access_config": "View and manage access permissions", - "app.acl.access_point_manage": "View and manage access points", "app.acl.delete": "Delete app", "app.acl.deploy": "Deploy app", "app.acl.edit": "Edit app information and orchestrate app", diff --git a/web/i18n/en-US/tools.json b/web/i18n/en-US/tools.json index d03a74b9209..adaa5d12a78 100644 --- a/web/i18n/en-US/tools.json +++ b/web/i18n/en-US/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Description", "createTool.toolOutput.reserved": "Reserved", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json, and files are reserved variables. Variables with these names cannot appear in the output schema.", + "createTool.toolOutput.sourceNode": "Source node", "createTool.toolOutput.title": "Tool Output", "createTool.urlError": "Please enter a valid URL", "createTool.viewSchemaSpec": "View the OpenAPI-Swagger Specification", diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json index d7d5e9d57c8..af3336ceea7 100644 --- a/web/i18n/es-ES/permission-keys.json +++ b/web/i18n/es-ES/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Gestionar la configuración de la extensión de API", "app.access_config": "Configurar los permisos de acceso de la app", "app.acl.access_config": "Ver y gestionar los permisos de acceso", - "app.acl.access_point_manage": "Ver y gestionar los puntos de acceso", "app.acl.delete": "Eliminar app", "app.acl.deploy": "Desplegar la app", "app.acl.edit": "Editar la información y orquestar la app", diff --git a/web/i18n/es-ES/tools.json b/web/i18n/es-ES/tools.json index 0fbc485adb6..7da12e6890a 100644 --- a/web/i18n/es-ES/tools.json +++ b/web/i18n/es-ES/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Descripción", "createTool.toolOutput.reserved": "Reservado", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json y files son variables reservadas. Las variables con estos nombres no pueden aparecer en el esquema de salida.", + "createTool.toolOutput.sourceNode": "Nodo de origen", "createTool.toolOutput.title": "Salida de la herramienta", "createTool.urlError": "Por favor, ingresa una URL válida", "createTool.viewSchemaSpec": "Ver la Especificación OpenAPI-Swagger", diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json index 5e739bed336..372374ed3b8 100644 --- a/web/i18n/fa-IR/permission-keys.json +++ b/web/i18n/fa-IR/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "مدیریت پیکربندی افزونه API", "app.access_config": "پیکربندی مجوزهای دسترسی برنامه", "app.acl.access_config": "مشاهده و مدیریت مجوزهای دسترسی", - "app.acl.access_point_manage": "مشاهده و مدیریت نقاط دسترسی", "app.acl.delete": "حذف برنامه", "app.acl.deploy": "استقرار برنامه", "app.acl.edit": "ویرایش اطلاعات برنامه و هماهنگ‌سازی برنامه", diff --git a/web/i18n/fa-IR/tools.json b/web/i18n/fa-IR/tools.json index f09383c28e1..82f0c6b854f 100644 --- a/web/i18n/fa-IR/tools.json +++ b/web/i18n/fa-IR/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "توضیحات", "createTool.toolOutput.reserved": "رزرو شده", "createTool.toolOutput.reservedParameterDuplicateTip": "متن، JSON و فایل‌ها متغیرهای رزرو شده هستند. متغیرهایی با این نام‌ها نمی‌توانند در طرح خروجی ظاهر شوند.", + "createTool.toolOutput.sourceNode": "گره منبع", "createTool.toolOutput.title": "خروجی ابزار", "createTool.urlError": "لطفاً یک URL معتبر وارد کنید", "createTool.viewSchemaSpec": "مشاهده مشخصات OpenAPI-Swagger", diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json index c547052586d..074da133fae 100644 --- a/web/i18n/fr-FR/permission-keys.json +++ b/web/i18n/fr-FR/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Gérer la configuration de l'extension API", "app.access_config": "Configurer les autorisations d'accès à l'application", "app.acl.access_config": "Afficher et gérer les autorisations d'accès", - "app.acl.access_point_manage": "Afficher et gérer les points d’accès", "app.acl.delete": "Supprimer l'application", "app.acl.deploy": "Déployer l'application", "app.acl.edit": "Modifier les informations et orchestrer l'application", diff --git a/web/i18n/fr-FR/tools.json b/web/i18n/fr-FR/tools.json index d97b0353288..d21da9776c8 100644 --- a/web/i18n/fr-FR/tools.json +++ b/web/i18n/fr-FR/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Description", "createTool.toolOutput.reserved": "Réservé", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json et files sont des variables réservées. Les variables portant ces noms ne peuvent pas apparaître dans le schéma de sortie.", + "createTool.toolOutput.sourceNode": "Nœud source", "createTool.toolOutput.title": "Sortie de l'outil", "createTool.urlError": "Veuillez entrer une URL valide", "createTool.viewSchemaSpec": "Voir la spécification OpenAPI-Swagger", diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json index 0779d870342..314cbfba84b 100644 --- a/web/i18n/hi-IN/permission-keys.json +++ b/web/i18n/hi-IN/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें", "app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", "app.acl.access_config": "एक्सेस अनुमतियाँ देखें और प्रबंधित करें", - "app.acl.access_point_manage": "एक्सेस पॉइंट देखें और प्रबंधित करें", "app.acl.delete": "ऐप हटाएं", "app.acl.deploy": "ऐप डिप्लॉय करें", "app.acl.edit": "ऐप की जानकारी संपादित करें और ऐप को ऑर्केस्ट्रेट करें", diff --git a/web/i18n/hi-IN/tools.json b/web/i18n/hi-IN/tools.json index 045f9eb2653..ed716295e12 100644 --- a/web/i18n/hi-IN/tools.json +++ b/web/i18n/hi-IN/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "विवरण", "createTool.toolOutput.reserved": "आरक्षित", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json, और फाइलें आरक्षित वेरिएबल हैं। इन नामों वाले वेरिएबल आउटपुट स्कीमा में दिखाई नहीं दे सकते।", + "createTool.toolOutput.sourceNode": "स्रोत नोड", "createTool.toolOutput.title": "उपकरण आउटपुट", "createTool.urlError": "कृपया एक मान्य URL दर्ज करें", "createTool.viewSchemaSpec": "OpenAPI-Swagger विनिर्देश देखें", diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json index 349bff75538..4b04ea96f00 100644 --- a/web/i18n/id-ID/permission-keys.json +++ b/web/i18n/id-ID/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Kelola konfigurasi ekstensi API", "app.access_config": "Konfigurasikan izin akses aplikasi", "app.acl.access_config": "Lihat dan kelola izin akses", - "app.acl.access_point_manage": "Lihat dan kelola titik akses", "app.acl.delete": "Hapus aplikasi", "app.acl.deploy": "Deploy aplikasi", "app.acl.edit": "Edit informasi aplikasi dan orkestrasikan aplikasi", diff --git a/web/i18n/id-ID/tools.json b/web/i18n/id-ID/tools.json index a3316811ffb..b0630bfaf6e 100644 --- a/web/i18n/id-ID/tools.json +++ b/web/i18n/id-ID/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Deskripsi", "createTool.toolOutput.reserved": "Dicadangkan", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json, dan file adalah variabel yang dicadangkan. Variabel dengan nama-nama ini tidak dapat muncul dalam skema keluaran.", + "createTool.toolOutput.sourceNode": "Node sumber", "createTool.toolOutput.title": "Keluaran Alat", "createTool.urlError": "Silakan masukkan URL yang valid", "createTool.viewSchemaSpec": "Lihat Spesifikasi OpenAPI-Swagger", diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json index b5f3ebf8094..899adce084b 100644 --- a/web/i18n/it-IT/permission-keys.json +++ b/web/i18n/it-IT/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Gestisci la configurazione delle estensioni API", "app.access_config": "Configura i permessi di accesso all'app", "app.acl.access_config": "Visualizza e gestisci i permessi di accesso", - "app.acl.access_point_manage": "Visualizza e gestisci i punti di accesso", "app.acl.delete": "Elimina app", "app.acl.deploy": "Distribuisci app", "app.acl.edit": "Modifica le informazioni e orchestra l'app", diff --git a/web/i18n/it-IT/tools.json b/web/i18n/it-IT/tools.json index fc54a2f9c71..f0bc0f59d28 100644 --- a/web/i18n/it-IT/tools.json +++ b/web/i18n/it-IT/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Descrizione", "createTool.toolOutput.reserved": "Riservato", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json e files sono variabili riservate. Le variabili con questi nomi non possono comparire nello schema di output.", + "createTool.toolOutput.sourceNode": "Nodo di origine", "createTool.toolOutput.title": "Output dello strumento", "createTool.urlError": "Per favore inserisci un URL valido", "createTool.viewSchemaSpec": "Visualizza la Specifica OpenAPI-Swagger", diff --git a/web/i18n/ja-JP/permission-keys.json b/web/i18n/ja-JP/permission-keys.json index 53033e2dc10..1b0c567f0e8 100644 --- a/web/i18n/ja-JP/permission-keys.json +++ b/web/i18n/ja-JP/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "API拡張設定を管理", "app.access_config": "アプリアクセス権限を設定", "app.acl.access_config": "アクセス権限の表示と管理", - "app.acl.access_point_manage": "アクセスポイントの表示と管理", "app.acl.delete": "アプリを削除", "app.acl.deploy": "アプリをデプロイ", "app.acl.edit": "アプリ情報の編集とアプリのオーケストレーション", diff --git a/web/i18n/ja-JP/tools.json b/web/i18n/ja-JP/tools.json index 13d4bbbb51a..adca26f4676 100644 --- a/web/i18n/ja-JP/tools.json +++ b/web/i18n/ja-JP/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "説明", "createTool.toolOutput.reserved": "予約済み", "createTool.toolOutput.reservedParameterDuplicateTip": "text、json、および files は予約語です。これらの名前の変数は出力スキーマに表示することはできません。", + "createTool.toolOutput.sourceNode": "ソースノード", "createTool.toolOutput.title": "ツール出力", "createTool.urlError": "有効な URL を入力してください", "createTool.viewSchemaSpec": "OpenAPI/Swagger 仕様を表示", diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json index 45517f6acb9..3a9981a602a 100644 --- a/web/i18n/ko-KR/permission-keys.json +++ b/web/i18n/ko-KR/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "API 확장 구성 관리", "app.access_config": "앱 접근 권한 구성", "app.acl.access_config": "접근 권한 보기 및 관리", - "app.acl.access_point_manage": "액세스 지점 보기 및 관리", "app.acl.delete": "앱 삭제", "app.acl.deploy": "앱 배포", "app.acl.edit": "앱 정보 편집 및 앱 오케스트레이션", diff --git a/web/i18n/ko-KR/tools.json b/web/i18n/ko-KR/tools.json index 85831b51635..702ffdcc9d0 100644 --- a/web/i18n/ko-KR/tools.json +++ b/web/i18n/ko-KR/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "설명", "createTool.toolOutput.reserved": "예약됨", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json, 파일은 예약된 변수입니다. 이러한 이름을 가진 변수는 출력 스키마에 나타날 수 없습니다.", + "createTool.toolOutput.sourceNode": "소스 노드", "createTool.toolOutput.title": "도구 출력", "createTool.urlError": "유효한 URL 을 입력하세요", "createTool.viewSchemaSpec": "OpenAPI-Swagger 명세 보기", diff --git a/web/i18n/lo-LA/permission-keys.json b/web/i18n/lo-LA/permission-keys.json index e04ef1f59ec..bfba51cc7e2 100644 --- a/web/i18n/lo-LA/permission-keys.json +++ b/web/i18n/lo-LA/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "ຈັດການການຕັ້ງຄ່າ API extension", "app.access_config": "ຕັ້ງຄ່າສິດການເຂົ້າເຖິງແອັບ", "app.acl.access_config": "ເບິ່ງ ແລະ ຈັດການສິດການເຂົ້າເຖິງ", - "app.acl.access_point_manage": "ເບິ່ງ ແລະ ຈັດການຈຸດເຂົ້າເຖິງ", "app.acl.delete": "ລຶບແອັບ", "app.acl.deploy": "ຕິດຕັ້ງແອັບ", "app.acl.edit": "ແກ້ໄຂຂໍ້ມູນແອັບ ແລະ ຈັດການລະບົບແອັບ", diff --git a/web/i18n/lo-LA/tools.json b/web/i18n/lo-LA/tools.json index 472c62f2de2..1f8c77ea9a0 100644 --- a/web/i18n/lo-LA/tools.json +++ b/web/i18n/lo-LA/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "ຄຳອະທິບາຍ", "createTool.toolOutput.reserved": "ສະຫງວນໄວ້", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json, ແລະ files ແມ່ນຕົວປ່ຽນທີ່ຖືກສະຫງວນໄວ້. ຕົວປ່ຽນທີ່ມີຊື່ເຫຼົ່ານີ້ບໍ່ສາມາດປາກົດໃນໂຄງສ້າງຜົນອອກ (output schema) ໄດ້.", + "createTool.toolOutput.sourceNode": "ໂຫນດຕົ້ນທາງ", "createTool.toolOutput.title": "ຜົນອອກຂອງເຄື່ອງມື (Tool Output)", "createTool.urlError": "ກະລຸນາປ້ອນ URL ທີ່ຖືກຕ້ອງ", "createTool.viewSchemaSpec": "ເບິ່ງຂໍ້ກຳນົດ OpenAPI-Swagger", diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json index 8266b38ae22..94fdf9d182c 100644 --- a/web/i18n/nl-NL/permission-keys.json +++ b/web/i18n/nl-NL/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "API-extensieconfiguratie beheren", "app.access_config": "Toegangsrechten voor app configureren", "app.acl.access_config": "Toegangsrechten bekijken en beheren", - "app.acl.access_point_manage": "Toegangspunten bekijken en beheren", "app.acl.delete": "App verwijderen", "app.acl.deploy": "App implementeren", "app.acl.edit": "App-informatie bewerken en app orkestreren", diff --git a/web/i18n/nl-NL/tools.json b/web/i18n/nl-NL/tools.json index a145b9366cd..cc47263292e 100644 --- a/web/i18n/nl-NL/tools.json +++ b/web/i18n/nl-NL/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Description", "createTool.toolOutput.reserved": "Reserved", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json, and files are reserved variables. Variables with these names cannot appear in the output schema.", + "createTool.toolOutput.sourceNode": "Bronknooppunt", "createTool.toolOutput.title": "Tool Output", "createTool.urlError": "Please enter a valid URL", "createTool.viewSchemaSpec": "View the OpenAPI-Swagger Specification", diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json index 392f470914e..55619735f16 100644 --- a/web/i18n/pl-PL/permission-keys.json +++ b/web/i18n/pl-PL/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API", "app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", "app.acl.access_config": "Wyświetlaj uprawnienia dostępu i zarządzaj nimi", - "app.acl.access_point_manage": "Wyświetlaj punkty dostępu i zarządzaj nimi", "app.acl.delete": "Usuń aplikację", "app.acl.deploy": "Wdróż aplikację", "app.acl.edit": "Edytuj informacje o aplikacji i orkiestruj aplikację", diff --git a/web/i18n/pl-PL/tools.json b/web/i18n/pl-PL/tools.json index 4d4f0add225..3ec851e1527 100644 --- a/web/i18n/pl-PL/tools.json +++ b/web/i18n/pl-PL/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Opis", "createTool.toolOutput.reserved": "Zarezerwowane", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json i pliki są zastrzeżonymi zmiennymi. Zmienne o tych nazwach nie mogą pojawiać się w schemacie wyjściowym.", + "createTool.toolOutput.sourceNode": "Węzeł źródłowy", "createTool.toolOutput.title": "Wynik narzędzia", "createTool.urlError": "Proszę podać prawidłowy URL", "createTool.viewSchemaSpec": "Zobacz specyfikację OpenAPI-Swagger", diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json index 36dd03d5719..32dda95b517 100644 --- a/web/i18n/pt-BR/permission-keys.json +++ b/web/i18n/pt-BR/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Gerenciar configuração de extensão de API", "app.access_config": "Configurar permissões de acesso ao aplicativo", "app.acl.access_config": "Visualizar e gerenciar permissões de acesso", - "app.acl.access_point_manage": "Visualizar e gerenciar pontos de acesso", "app.acl.delete": "Excluir aplicativo", "app.acl.deploy": "Implantar aplicativo", "app.acl.edit": "Editar informações e orquestrar o aplicativo", diff --git a/web/i18n/pt-BR/tools.json b/web/i18n/pt-BR/tools.json index 2f1afe9bc8a..7562d10bdf9 100644 --- a/web/i18n/pt-BR/tools.json +++ b/web/i18n/pt-BR/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Descrição", "createTool.toolOutput.reserved": "Reservado", "createTool.toolOutput.reservedParameterDuplicateTip": "texto, json e arquivos são variáveis reservadas. Variáveis com esses nomes não podem aparecer no esquema de saída.", + "createTool.toolOutput.sourceNode": "Nó de origem", "createTool.toolOutput.title": "Saída da ferramenta", "createTool.urlError": "Digite uma URL válida", "createTool.viewSchemaSpec": "Ver a Especificação OpenAPI-Swagger", diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json index 73225f7cd60..2610e185492 100644 --- a/web/i18n/ro-RO/permission-keys.json +++ b/web/i18n/ro-RO/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Gestionează configurația extensiei API", "app.access_config": "Configurează permisiunile de acces ale aplicației", "app.acl.access_config": "Vizualizează și gestionează permisiunile de acces", - "app.acl.access_point_manage": "Vizualizează și gestionează punctele de acces", "app.acl.delete": "Șterge aplicația", "app.acl.deploy": "Implementează aplicația", "app.acl.edit": "Editează informațiile aplicației și orchestrează aplicația", diff --git a/web/i18n/ro-RO/tools.json b/web/i18n/ro-RO/tools.json index cc54c2ba457..a4a542dde50 100644 --- a/web/i18n/ro-RO/tools.json +++ b/web/i18n/ro-RO/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Descriere", "createTool.toolOutput.reserved": "Rezervat", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json și fișiere sunt variabile rezervate. Variabilele cu aceste nume nu pot apărea în schema de ieșire.", + "createTool.toolOutput.sourceNode": "Nod sursă", "createTool.toolOutput.title": "Ieșire instrument", "createTool.urlError": "Vă rugăm să introduceți un URL valid", "createTool.viewSchemaSpec": "Vezi specificația OpenAPI-Swagger", diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json index bd986d65e95..574f0e96add 100644 --- a/web/i18n/ru-RU/permission-keys.json +++ b/web/i18n/ru-RU/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Управление конфигурацией API-расширений", "app.access_config": "Настройка прав доступа к приложению", "app.acl.access_config": "Просмотр и управление правами доступа", - "app.acl.access_point_manage": "Просмотр и управление точками доступа", "app.acl.delete": "Удаление приложения", "app.acl.deploy": "Развертывание приложения", "app.acl.edit": "Редактирование информации о приложении и оркестрация приложения", diff --git a/web/i18n/ru-RU/tools.json b/web/i18n/ru-RU/tools.json index 041225f7480..44242d4221e 100644 --- a/web/i18n/ru-RU/tools.json +++ b/web/i18n/ru-RU/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Описание", "createTool.toolOutput.reserved": "Зарезервировано", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json и files — зарезервированные переменные. Переменные с этими именами не могут появляться в схеме вывода.", + "createTool.toolOutput.sourceNode": "Исходный узел", "createTool.toolOutput.title": "Вывод инструмента", "createTool.urlError": "Пожалуйста, введите действительный URL", "createTool.viewSchemaSpec": "Посмотреть спецификацию OpenAPI-Swagger", diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json index 4a49494a2c2..544c6f92a8d 100644 --- a/web/i18n/sl-SI/permission-keys.json +++ b/web/i18n/sl-SI/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Upravljanje konfiguracije razširitve API", "app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", "app.acl.access_config": "Ogled in upravljanje dovoljenj za dostop", - "app.acl.access_point_manage": "Ogled in upravljanje dostopnih točk", "app.acl.delete": "Izbriši aplikacijo", "app.acl.deploy": "Uvedi aplikacijo", "app.acl.edit": "Uredi podatke o aplikaciji in orkestriraj aplikacijo", diff --git a/web/i18n/sl-SI/tools.json b/web/i18n/sl-SI/tools.json index 9cb9fb3ea83..3f1faf5298d 100644 --- a/web/i18n/sl-SI/tools.json +++ b/web/i18n/sl-SI/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Opis", "createTool.toolOutput.reserved": "Rezervirano", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json in datoteke so rezervirane spremenljivke. Spremenljivke s temi imeni se ne smejo pojaviti v izhodni shemi.", + "createTool.toolOutput.sourceNode": "Izvorno vozlišče", "createTool.toolOutput.title": "Izhod orodja", "createTool.urlError": "Prosimo, vnesite veljaven URL", "createTool.viewSchemaSpec": "Oglejte si OpenAPI-Swagger specifikacijo", diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json index 0ad4047ef26..b7b9854abf0 100644 --- a/web/i18n/th-TH/permission-keys.json +++ b/web/i18n/th-TH/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API", "app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", "app.acl.access_config": "ดูและจัดการสิทธิ์การเข้าถึง", - "app.acl.access_point_manage": "ดูและจัดการจุดเข้าถึง", "app.acl.delete": "ลบแอป", "app.acl.deploy": "ปรับใช้แอป", "app.acl.edit": "แก้ไขข้อมูลแอปและจัดวางแอป", diff --git a/web/i18n/th-TH/tools.json b/web/i18n/th-TH/tools.json index 42de755b0bd..841a1d83fab 100644 --- a/web/i18n/th-TH/tools.json +++ b/web/i18n/th-TH/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "คำอธิบาย", "createTool.toolOutput.reserved": "สงวน", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json และ files เป็นตัวแปรที่สงวนไว้ ไม่สามารถใช้ชื่อตัวแปรเหล่านี้ในโครงสร้างผลลัพธ์ได้", + "createTool.toolOutput.sourceNode": "โหนดต้นทาง", "createTool.toolOutput.title": "เอาต์พุตของเครื่องมือ", "createTool.urlError": "โปรดป้อน URL ที่ถูกต้อง", "createTool.viewSchemaSpec": "ดูข้อมูลจําเพาะของ OpenAPI-Swagger", diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json index 781d9d00d38..36ba8ec9709 100644 --- a/web/i18n/tr-TR/permission-keys.json +++ b/web/i18n/tr-TR/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "API uzantısı yapılandırmasını yönet", "app.access_config": "Uygulama erişim izinlerini yapılandır", "app.acl.access_config": "Erişim izinlerini görüntüle ve yönet", - "app.acl.access_point_manage": "Erişim noktalarını görüntüle ve yönet", "app.acl.delete": "Uygulamayı sil", "app.acl.deploy": "Uygulamayı dağıt", "app.acl.edit": "Uygulama bilgilerini düzenle ve uygulamayı orkestre et", diff --git a/web/i18n/tr-TR/tools.json b/web/i18n/tr-TR/tools.json index f0202003032..7e005afa20b 100644 --- a/web/i18n/tr-TR/tools.json +++ b/web/i18n/tr-TR/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Açıklama", "createTool.toolOutput.reserved": "Ayrılmış", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json ve dosyalar ayrılmış değişkenlerdir. Bu isimlere sahip değişkenler çıktı şemasında yer alamaz.", + "createTool.toolOutput.sourceNode": "Kaynak düğüm", "createTool.toolOutput.title": "Araç Çıktısı", "createTool.urlError": "Geçerli bir URL girin", "createTool.viewSchemaSpec": "OpenAPI-Swagger Spesifikasyonunu Görüntüle", diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json index 8cfd28d2548..861c83a4367 100644 --- a/web/i18n/uk-UA/permission-keys.json +++ b/web/i18n/uk-UA/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Керування конфігурацією розширення API", "app.access_config": "Налаштування дозволів доступу до застосунку", "app.acl.access_config": "Переглядати дозволи доступу та керувати ними", - "app.acl.access_point_manage": "Переглядати точки доступу та керувати ними", "app.acl.delete": "Видалити застосунок", "app.acl.deploy": "Розгорнути застосунок", "app.acl.edit": "Редагувати інформацію про застосунок та оркеструвати застосунок", diff --git a/web/i18n/uk-UA/tools.json b/web/i18n/uk-UA/tools.json index 195c4bb4bbb..ec464b021e5 100644 --- a/web/i18n/uk-UA/tools.json +++ b/web/i18n/uk-UA/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Опис", "createTool.toolOutput.reserved": "Зарезервовано", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json та файли є зарезервованими змінними. Змінні з такими іменами не можуть з’являтися в схемі вихідних даних.", + "createTool.toolOutput.sourceNode": "Вихідний вузол", "createTool.toolOutput.title": "Вихідні дані інструменту", "createTool.urlError": "Введіть дійсну URL-адресу", "createTool.viewSchemaSpec": "Переглянути специфікацію OpenAPI-Swagger", diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json index 2290d6362e0..1e6662a9304 100644 --- a/web/i18n/vi-VN/permission-keys.json +++ b/web/i18n/vi-VN/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "Quản lý cấu hình phần mở rộng API", "app.access_config": "Cấu hình quyền truy cập ứng dụng", "app.acl.access_config": "Xem và quản lý quyền truy cập", - "app.acl.access_point_manage": "Xem và quản lý điểm truy cập", "app.acl.delete": "Xóa ứng dụng", "app.acl.deploy": "Triển khai ứng dụng", "app.acl.edit": "Chỉnh sửa thông tin và điều phối ứng dụng", diff --git a/web/i18n/vi-VN/tools.json b/web/i18n/vi-VN/tools.json index 2659332f2e4..2835d2fc7b5 100644 --- a/web/i18n/vi-VN/tools.json +++ b/web/i18n/vi-VN/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "Mô tả", "createTool.toolOutput.reserved": "Dành riêng", "createTool.toolOutput.reservedParameterDuplicateTip": "text, json và files là các biến dành riêng. Các biến có tên này không thể xuất hiện trong sơ đồ đầu ra.", + "createTool.toolOutput.sourceNode": "Nút nguồn", "createTool.toolOutput.title": "Đầu ra của công cụ", "createTool.urlError": "Vui lòng nhập URL hợp lệ", "createTool.viewSchemaSpec": "Xem chi tiết OpenAPI-Swagger", diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json index db8184ee533..91d9afb2cc8 100644 --- a/web/i18n/zh-Hans/permission-keys.json +++ b/web/i18n/zh-Hans/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "管理API扩展", "app.access_config": "配置应用访问权限", "app.acl.access_config": "查看与管理访问权限", - "app.acl.access_point_manage": "查看与管理访问点", "app.acl.delete": "删除应用", "app.acl.deploy": "部署应用", "app.acl.edit": "编辑应用信息与编排应用", diff --git a/web/i18n/zh-Hans/tools.json b/web/i18n/zh-Hans/tools.json index 01a6659d229..884a568437b 100644 --- a/web/i18n/zh-Hans/tools.json +++ b/web/i18n/zh-Hans/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "描述", "createTool.toolOutput.reserved": "预留", "createTool.toolOutput.reservedParameterDuplicateTip": "text、json、files 是预留变量,这些名称的变量不能出现在 output_schema 中。", + "createTool.toolOutput.sourceNode": "来源节点", "createTool.toolOutput.title": "工具出参", "createTool.urlError": "请输入有效的 URL", "createTool.viewSchemaSpec": "查看 OpenAPI-Swagger 规范", diff --git a/web/i18n/zh-Hant/permission-keys.json b/web/i18n/zh-Hant/permission-keys.json index 8a74b37f086..43350a59ada 100644 --- a/web/i18n/zh-Hant/permission-keys.json +++ b/web/i18n/zh-Hant/permission-keys.json @@ -3,7 +3,6 @@ "api_extension.manage": "管理API擴充配置", "app.access_config": "配置應用訪問權限", "app.acl.access_config": "檢視與管理存取權限", - "app.acl.access_point_manage": "檢視與管理存取點", "app.acl.delete": "刪除應用", "app.acl.deploy": "部署應用", "app.acl.edit": "編輯應用資訊與編排應用", diff --git a/web/i18n/zh-Hant/tools.json b/web/i18n/zh-Hant/tools.json index 6298f30b22b..78c99ced8e5 100644 --- a/web/i18n/zh-Hant/tools.json +++ b/web/i18n/zh-Hant/tools.json @@ -93,6 +93,7 @@ "createTool.toolOutput.description": "描述", "createTool.toolOutput.reserved": "已保留", "createTool.toolOutput.reservedParameterDuplicateTip": "text、json 和 files 是保留變數。這些名稱的變數不能出現在輸出結構中。", + "createTool.toolOutput.sourceNode": "來源節點", "createTool.toolOutput.title": "工具輸出", "createTool.urlError": "請輸入有效的 URL", "createTool.viewSchemaSpec": "檢視 OpenAPI-Swagger 規範", diff --git a/web/next.config.ts b/web/next.config.ts index aff1c791d67..adceb7f630f 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -17,11 +17,6 @@ const nextConfig: NextConfig = { bundler: 'turbopack', }), }, - experimental: { - // TODO: Remove when the `typescript` package can point to TypeScript 7. - // Next.js resolves that package, while compiler-API consumers still require TypeScript 6. - useTypeScriptCli: false, - }, productionBrowserSourceMaps: false, // enable browser source map generation during the production build typescript: { // https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors diff --git a/web/plugins/eslint/index.js b/web/plugins/eslint/index.js index 09af24bbf7b..2fd685cb217 100644 --- a/web/plugins/eslint/index.js +++ b/web/plugins/eslint/index.js @@ -2,6 +2,7 @@ import consistentPlaceholders from './rules/consistent-placeholders.js' import i18nFlatKey from './rules/i18n-flat-key.js' import noExtraKeys from './rules/no-extra-keys.js' import preferTailwindIcons from './rules/prefer-tailwind-icons.js' +import requireTitleForTruncatedText from './rules/require-title-for-truncated-text.js' /** @type {import('eslint').ESLint.Plugin} */ const plugin = { @@ -14,6 +15,7 @@ const plugin = { 'i18n-flat-key': i18nFlatKey, 'no-extra-keys': noExtraKeys, 'prefer-tailwind-icons': preferTailwindIcons, + 'require-title-for-truncated-text': requireTitleForTruncatedText, }, } diff --git a/web/plugins/eslint/rules/fixtures/truncation.module.css b/web/plugins/eslint/rules/fixtures/truncation.module.css new file mode 100644 index 00000000000..fb81f59ed30 --- /dev/null +++ b/web/plugins/eslint/rules/fixtures/truncation.module.css @@ -0,0 +1,23 @@ +.singleLine { + @apply truncate; +} + +.multipleLines { + -webkit-line-clamp: 2; +} + +.noClamp { + line-clamp: none; +} + +.zeroClamp { + -webkit-line-clamp: 0; +} + +.unsetClamp { + line-clamp: unset !important; +} + +.regular { + overflow: hidden; +} diff --git a/web/plugins/eslint/rules/require-title-for-truncated-text.js b/web/plugins/eslint/rules/require-title-for-truncated-text.js new file mode 100644 index 00000000000..d0c77ba52d9 --- /dev/null +++ b/web/plugins/eslint/rules/require-title-for-truncated-text.js @@ -0,0 +1,743 @@ +import { readFileSync, statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const DEFAULT_TRUNCATION_CLASSES = new Set(['text-ellipsis', 'truncate']) +const cssModuleClassCache = new Map() +const UNSAFE_TITLE_EXPRESSION = Symbol('unsafe-title-expression') +const STYLE_WRAPPER_TYPES = new Set([ + 'ChainExpression', + 'TSAsExpression', + 'TSInstantiationExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) + +function getJsxName(node) { + if (!node) return null + if (node.type === 'JSXIdentifier') return node.name + if (node.type === 'JSXMemberExpression') { + const objectName = getJsxName(node.object) + const propertyName = getJsxName(node.property) + return objectName && propertyName ? `${objectName}.${propertyName}` : null + } + if (node.type === 'JSXNamespacedName') { + const namespaceName = getJsxName(node.namespace) + const name = getJsxName(node.name) + return namespaceName && name ? `${namespaceName}:${name}` : null + } + return null +} + +function getAttribute(openingElement, attributeName) { + return openingElement.attributes.find( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === attributeName, + ) +} + +function unwrapExpression(node) { + let current = node + while (current && STYLE_WRAPPER_TYPES.has(current.type)) current = current.expression + return current +} + +function getStaticString(node) { + const current = unwrapExpression(node) + if (!current) return null + if (current.type === 'Literal' && typeof current.value === 'string') return current.value + if (current.type === 'TemplateLiteral' && current.expressions.length === 0) + return current.quasis[0]?.value.cooked ?? current.quasis[0]?.value.raw ?? '' + return null +} + +function getUnprefixedClassName(token) { + let bracketDepth = 0 + let lastVariantSeparator = -1 + + for (let index = 0; index < token.length; index++) { + const character = token[index] + if (character === '[' || character === '(') bracketDepth++ + else if (character === ']' || character === ')') bracketDepth = Math.max(0, bracketDepth - 1) + else if (character === ':' && bracketDepth === 0) lastVariantSeparator = index + } + + return token + .slice(lastVariantSeparator + 1) + .replace(/^!/u, '') + .replace(/!$/u, '') +} + +function isTruncationClassToken(token) { + const className = getUnprefixedClassName(token) + if (DEFAULT_TRUNCATION_CLASSES.has(className)) return true + if (className.startsWith('line-clamp-') && className !== 'line-clamp-none') return true + + const normalizedArbitraryClass = className.replaceAll(' ', '').toLowerCase() + return ( + normalizedArbitraryClass === '[text-overflow:ellipsis]' || + /^\[(?:-webkit-)?line-clamp:(?!none(?:\]|$))[^\]]+\]$/u.test(normalizedArbitraryClass) + ) +} + +function stringContainsTruncationClass(value) { + return value.split(/\s+/u).some((token) => token && isTruncationClassToken(token)) +} + +function hasActiveLineClampDeclaration(cssText) { + const lineClampPattern = /(?:-webkit-)?line-clamp\s*:\s*([^;}]+)/giu + return [...cssText.matchAll(lineClampPattern)].some((match) => { + const value = (match[1] ?? '').trim() + return value !== '' && !/^(?:0|none|unset)\b/iu.test(value) + }) +} + +function getTruncatingCssModuleClassNames(cssText) { + const classNames = new Set() + const classBlockPattern = /\.([A-Z_a-z][\w-]*)\s*\{([^{}]*)\}/gu + + for (const match of cssText.matchAll(classBlockPattern)) { + const [, className, body = ''] = match + const hasTextEllipsis = /text-overflow\s*:\s*ellipsis\b/iu.test(body) + const hasLineClamp = hasActiveLineClampDeclaration(body) + const hasTruncationApply = [...body.matchAll(/@apply\s+([^;}]+)/gu)].some((applyMatch) => + stringContainsTruncationClass(applyMatch[1] ?? ''), + ) + + if (hasTextEllipsis || hasLineClamp || hasTruncationApply) classNames.add(className) + } + + return classNames +} + +function getCssModuleClassNames(cssModulePath) { + try { + const modifiedTime = statSync(cssModulePath).mtimeMs + const cached = cssModuleClassCache.get(cssModulePath) + if (cached?.modifiedTime === modifiedTime) return cached.classNames + + const classNames = getTruncatingCssModuleClassNames(readFileSync(cssModulePath, 'utf8')) + cssModuleClassCache.set(cssModulePath, { classNames, modifiedTime }) + return classNames + } catch { + return new Set() + } +} + +function getPropertyName(property) { + if (!property || property.type !== 'Property') return null + if (!property.computed && property.key.type === 'Identifier') return property.key.name + if (property.key.type === 'Literal' && typeof property.key.value === 'string') + return property.key.value + return null +} + +function getVariableByName(scope, name) { + let currentScope = scope + while (currentScope) { + const variable = currentScope.set.get(name) + if (variable) return variable + currentScope = currentScope.upper + } + return null +} + +function getVariableInitializer(identifier, sourceCode) { + const variable = getVariableByName(sourceCode.getScope(identifier), identifier.name) + if (!variable || variable.defs.length !== 1) return null + + const [definition] = variable.defs + if ( + definition.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + definition.node.id.type !== 'Identifier' || + !definition.node.init + ) + return null + + return { initializer: definition.node.init, variable } +} + +function expressionContainsTruncationClass( + node, + sourceCode, + cssModuleBindings, + seenVariables = new Set(), +) { + const current = unwrapExpression(node) + if (!current) return false + + const staticString = getStaticString(current) + if (staticString !== null) return stringContainsTruncationClass(staticString) + + switch (current.type) { + case 'Identifier': { + const resolvedVariable = getVariableInitializer(current, sourceCode) + if (!resolvedVariable || seenVariables.has(resolvedVariable.variable)) return false + const nextSeenVariables = new Set(seenVariables) + nextSeenVariables.add(resolvedVariable.variable) + return expressionContainsTruncationClass( + resolvedVariable.initializer, + sourceCode, + cssModuleBindings, + nextSeenVariables, + ) + } + case 'MemberExpression': { + const object = unwrapExpression(current.object) + if (object?.type !== 'Identifier') return false + const propertyName = current.computed + ? getStaticString(current.property) + : current.property.type === 'Identifier' + ? current.property.name + : null + const variable = getVariableByName(sourceCode.getScope(object), object.name) + return propertyName !== null && cssModuleBindings.get(variable)?.has(propertyName) + } + case 'TemplateLiteral': + return ( + current.quasis.some((quasi) => + stringContainsTruncationClass(quasi.value.cooked ?? quasi.value.raw), + ) || + current.expressions.some((expression) => + expressionContainsTruncationClass( + expression, + sourceCode, + cssModuleBindings, + seenVariables, + ), + ) + ) + case 'TaggedTemplateExpression': + return expressionContainsTruncationClass( + current.quasi, + sourceCode, + cssModuleBindings, + seenVariables, + ) + case 'CallExpression': + case 'NewExpression': + return ( + expressionContainsTruncationClass( + current.callee, + sourceCode, + cssModuleBindings, + seenVariables, + ) || + current.arguments.some((argument) => + argument.type === 'SpreadElement' + ? expressionContainsTruncationClass( + argument.argument, + sourceCode, + cssModuleBindings, + seenVariables, + ) + : expressionContainsTruncationClass( + argument, + sourceCode, + cssModuleBindings, + seenVariables, + ), + ) + ) + case 'ConditionalExpression': + return ( + expressionContainsTruncationClass( + current.consequent, + sourceCode, + cssModuleBindings, + seenVariables, + ) || + expressionContainsTruncationClass( + current.alternate, + sourceCode, + cssModuleBindings, + seenVariables, + ) + ) + case 'LogicalExpression': + case 'BinaryExpression': + return ( + expressionContainsTruncationClass( + current.left, + sourceCode, + cssModuleBindings, + seenVariables, + ) || + expressionContainsTruncationClass( + current.right, + sourceCode, + cssModuleBindings, + seenVariables, + ) + ) + case 'ArrayExpression': + return current.elements.some( + (element) => + element && + (element.type === 'SpreadElement' + ? expressionContainsTruncationClass( + element.argument, + sourceCode, + cssModuleBindings, + seenVariables, + ) + : expressionContainsTruncationClass( + element, + sourceCode, + cssModuleBindings, + seenVariables, + )), + ) + case 'ObjectExpression': + return current.properties.some((property) => { + if (property.type === 'SpreadElement') + return expressionContainsTruncationClass( + property.argument, + sourceCode, + cssModuleBindings, + seenVariables, + ) + + const propertyName = getPropertyName(property) + return ( + (propertyName !== null && stringContainsTruncationClass(propertyName)) || + expressionContainsTruncationClass( + property.value, + sourceCode, + cssModuleBindings, + seenVariables, + ) + ) + }) + case 'SequenceExpression': + return current.expressions.some((expression) => + expressionContainsTruncationClass(expression, sourceCode, cssModuleBindings, seenVariables), + ) + default: + return false + } +} + +function isActiveLineClampValue(node) { + const current = unwrapExpression(node) + if (!current) return false + if (current.type === 'Literal') { + if (current.value === null || current.value === 0) return false + if (typeof current.value === 'string') + return !['', '0', 'none', 'unset'].includes(current.value.trim().toLowerCase()) + return true + } + return current.type !== 'Identifier' || current.name !== 'undefined' +} + +function expressionContainsTruncationStyle(node, sourceCode, seenVariables = new Set()) { + const current = unwrapExpression(node) + if (!current) return false + + if (current.type === 'Identifier') { + const resolvedVariable = getVariableInitializer(current, sourceCode) + if (!resolvedVariable || seenVariables.has(resolvedVariable.variable)) return false + const nextSeenVariables = new Set(seenVariables) + nextSeenVariables.add(resolvedVariable.variable) + return expressionContainsTruncationStyle( + resolvedVariable.initializer, + sourceCode, + nextSeenVariables, + ) + } + + if (current.type === 'ConditionalExpression') { + return ( + expressionContainsTruncationStyle(current.consequent, sourceCode, seenVariables) || + expressionContainsTruncationStyle(current.alternate, sourceCode, seenVariables) + ) + } + + if (current.type === 'LogicalExpression') { + return ( + expressionContainsTruncationStyle(current.left, sourceCode, seenVariables) || + expressionContainsTruncationStyle(current.right, sourceCode, seenVariables) + ) + } + + if (current.type === 'ArrayExpression') { + return current.elements.some( + (element) => + element && + expressionContainsTruncationStyle( + element.type === 'SpreadElement' ? element.argument : element, + sourceCode, + seenVariables, + ), + ) + } + + if (current.type !== 'ObjectExpression') return false + + return current.properties.some((property) => { + if (property.type === 'SpreadElement') + return expressionContainsTruncationStyle(property.argument, sourceCode, seenVariables) + + const propertyName = getPropertyName(property) + if (propertyName === 'textOverflow' || propertyName === 'text-overflow') + return getStaticString(property.value)?.trim().toLowerCase() === 'ellipsis' + if ( + ['WebkitLineClamp', 'webkitLineClamp', 'lineClamp', '-webkit-line-clamp'].includes( + propertyName, + ) + ) + return isActiveLineClampValue(property.value) + return false + }) +} + +function hasTruncation(openingElement, sourceCode, cssModuleBindings) { + const classAttribute = + getAttribute(openingElement, 'className') ?? getAttribute(openingElement, 'class') + if (classAttribute?.value) { + if ( + classAttribute.value.type === 'Literal' && + typeof classAttribute.value.value === 'string' && + stringContainsTruncationClass(classAttribute.value.value) + ) + return true + if ( + classAttribute.value.type === 'JSXExpressionContainer' && + expressionContainsTruncationClass( + classAttribute.value.expression, + sourceCode, + cssModuleBindings, + ) + ) + return true + } + + const styleAttribute = getAttribute(openingElement, 'style') + return ( + styleAttribute?.value?.type === 'JSXExpressionContainer' && + expressionContainsTruncationStyle(styleAttribute.value.expression, sourceCode) + ) +} + +function isEmptyTitleAttribute(attribute) { + if (!attribute || !attribute.value) return true + if (attribute.value.type === 'Literal') + return typeof attribute.value.value !== 'string' || attribute.value.value.trim() === '' + if (attribute.value.type !== 'JSXExpressionContainer') return false + + const expression = unwrapExpression(attribute.value.expression) + if (!expression || expression.type === 'JSXEmptyExpression') return true + if (expression.type === 'Literal') + return expression.value === null || String(expression.value).trim() === '' + if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) + return (expression.quasis[0]?.value.cooked ?? '').trim() === '' + return expression.type === 'Identifier' && expression.name === 'undefined' +} + +function isRenderableTitleExpression(node) { + const current = unwrapExpression(node) + if (!current) return false + return ![ + 'ArrowFunctionExpression', + 'FunctionExpression', + 'JSXElement', + 'JSXEmptyExpression', + 'JSXFragment', + 'ObjectExpression', + 'SequenceExpression', + ].includes(current.type) +} + +function isSafeToDuplicateTitleExpression(node) { + const current = unwrapExpression(node) + if (!current) return false + + if (current.type === 'Identifier') return current.name !== 'undefined' + if (current.type === 'Literal') + return ( + current.value !== null && !['boolean', 'object', 'undefined'].includes(typeof current.value) + ) + if (current.type === 'TemplateLiteral' && current.expressions.length === 0) + return (current.quasis[0]?.value.cooked ?? '').trim() !== '' + return false +} + +function getMeaningfulChildren(element) { + return element.children.filter((child) => { + if (child.type === 'JSXText') return child.value.trim() !== '' + if (child.type === 'JSXExpressionContainer') + return child.expression.type !== 'JSXEmptyExpression' + return true + }) +} + +function getSingleTextChild(element) { + const meaningfulChildren = getMeaningfulChildren(element) + if (meaningfulChildren.length !== 1) return null + + const child = meaningfulChildren[0] + if (child.type === 'JSXElement') return getSingleTextChild(child) + return child +} + +function getExpressionTextChildCount(node) { + const current = unwrapExpression(node) + if (!current) return 0 + + if (current.type === 'JSXElement' || current.type === 'JSXFragment') + return getTextChildCount(current) + if (current.type === 'ConditionalExpression') + return Math.max( + getExpressionTextChildCount(current.consequent), + getExpressionTextChildCount(current.alternate), + ) + if (current.type === 'LogicalExpression') { + if (current.operator === '&&') return getExpressionTextChildCount(current.right) + return Math.max( + getExpressionTextChildCount(current.left), + getExpressionTextChildCount(current.right), + ) + } + if (current.type === 'ArrayExpression') { + let count = 0 + for (const element of current.elements) { + if (!element) continue + count += getExpressionTextChildCount( + element.type === 'SpreadElement' ? element.argument : element, + ) + if (count > 1) return count + } + return count + } + if (current.type === 'Literal') { + if (current.value === null || typeof current.value === 'boolean') return 0 + if (typeof current.value === 'string') return current.value.trim() === '' ? 0 : 1 + } + if (current.type === 'TemplateLiteral' && current.expressions.length === 0) + return (current.quasis[0]?.value.cooked ?? '').trim() === '' ? 0 : 1 + if (current.type === 'Identifier' && current.name === 'undefined') return 0 + return isRenderableTitleExpression(current) ? 1 : 0 +} + +function getTextChildCount(element) { + let count = 0 + + for (const child of getMeaningfulChildren(element)) { + if (child.type === 'JSXText') count++ + else if (child.type === 'JSXElement' || child.type === 'JSXFragment') + count += getTextChildCount(child) + else if (child.type === 'JSXExpressionContainer') + count += getExpressionTextChildCount(child.expression) + + if (count > 1) return count + } + + return count +} + +function hasMultipleTextChildren(openingElement) { + const element = openingElement.parent + return element?.type === 'JSXElement' && getTextChildCount(element) > 1 +} + +function getTitleFixTextFromAttribute(attribute, sourceCode) { + if (!attribute?.value) return null + if (attribute.value.type === 'Literal') { + if (typeof attribute.value.value !== 'string' || attribute.value.value.trim() === '') + return null + return `title=${JSON.stringify(attribute.value.value)}` + } + if (attribute.value.type !== 'JSXExpressionContainer') return null + if (!isSafeToDuplicateTitleExpression(attribute.value.expression)) return UNSAFE_TITLE_EXPRESSION + return `title={${sourceCode.getText(attribute.value.expression)}}` +} + +function getTitleFixText(openingElement, sourceCode) { + const element = openingElement.parent + if (!element || element.type !== 'JSXElement') return null + + const child = getSingleTextChild(element) + if (child?.type === 'JSXText') { + const value = child.value.trim().replace(/\s+/gu, ' ') + return value ? `title=${JSON.stringify(value)}` : null + } + let hasUnsafeTitleExpression = false + if (child?.type === 'JSXExpressionContainer') { + if (isSafeToDuplicateTitleExpression(child.expression)) + return `title={${sourceCode.getText(child.expression)}}` + hasUnsafeTitleExpression = true + } + + for (const attributeName of ['value', 'placeholder', 'content', 'label', 'name', 'aria-label']) { + const attributeFixText = getTitleFixTextFromAttribute( + getAttribute(openingElement, attributeName), + sourceCode, + ) + if (attributeFixText === UNSAFE_TITLE_EXPRESSION) { + hasUnsafeTitleExpression = true + continue + } + if (attributeFixText) return attributeFixText + } + + return hasUnsafeTitleExpression ? UNSAFE_TITLE_EXPRESSION : null +} + +function hasTitledSingleChild(openingElement) { + const element = openingElement.parent + if (!element || element.type !== 'JSXElement') return false + const meaningfulChildren = getMeaningfulChildren(element) + if (meaningfulChildren.length !== 1 || meaningfulChildren[0].type !== 'JSXElement') return null + const titleAttribute = getAttribute(meaningfulChildren[0].openingElement, 'title') + return !!titleAttribute && !isEmptyTitleAttribute(titleAttribute) +} + +function isTooltipTriggerOpening(openingElement, tooltipTriggerNames) { + const name = getJsxName(openingElement.name) + return name !== null && tooltipTriggerNames.has(name) +} + +function isInsideTooltipTrigger(openingElement, tooltipTriggerNames) { + let current = openingElement.parent + while (current) { + if ( + current.type === 'JSXElement' && + isTooltipTriggerOpening(current.openingElement, tooltipTriggerNames) + ) + return true + if ( + current.type === 'JSXAttribute' && + current.name.type === 'JSXIdentifier' && + current.name.name === 'render' && + current.parent?.type === 'JSXOpeningElement' && + isTooltipTriggerOpening(current.parent, tooltipTriggerNames) + ) + return true + current = current.parent + } + return false +} + +function getOwningVariableName(openingElement) { + let current = openingElement.parent + while (current) { + if (current.type === 'VariableDeclarator') + return current.id.type === 'Identifier' ? current.id.name : null + if ( + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' || + current.type === 'ArrowFunctionExpression' + ) + return null + current = current.parent + } + return null +} + +function collectReferencedIdentifiers(node, names) { + const current = unwrapExpression(node) + if (!current) return + if (current.type === 'Identifier') { + names.add(current.name) + return + } + if (current.type === 'ConditionalExpression') { + collectReferencedIdentifiers(current.consequent, names) + collectReferencedIdentifiers(current.alternate, names) + } else if (current.type === 'LogicalExpression') { + collectReferencedIdentifiers(current.left, names) + collectReferencedIdentifiers(current.right, names) + } +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'suggestion', + docs: { + description: 'Flag truncated JSX text for manual full-content disclosure review', + }, + messages: { + emptyTitle: + 'Review this truncated text against the disclosure policy; do not replace its existing title automatically.', + missingTitle: + 'Review this truncated text against the AUTO, COVERED, SKIP, and REVIEW disclosure policy.', + }, + schema: [], + }, + create(context) { + const candidates = [] + const renderIdentifiers = new Set() + const tooltipTriggerNames = new Set() + const cssModuleBindings = new Map() + + function recordCssModuleImport(node) { + if (typeof node.source.value !== 'string' || !node.source.value.endsWith('.module.css')) + return + const defaultSpecifier = node.specifiers.find( + (specifier) => specifier.type === 'ImportDefaultSpecifier', + ) + if (!defaultSpecifier) return + + const filename = context.filename || context.getFilename() + if (!filename || filename.startsWith('<')) return + const cssModulePath = resolve(dirname(filename), node.source.value) + const classNames = getCssModuleClassNames(cssModulePath) + const variable = context.sourceCode.getDeclaredVariables(defaultSpecifier)[0] + if (classNames.size > 0 && variable) cssModuleBindings.set(variable, classNames) + } + + return { + ImportDeclaration(node) { + recordCssModuleImport(node) + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ImportSpecifier' && + (specifier.imported.name ?? specifier.imported.value) === 'TooltipTrigger' + ) + tooltipTriggerNames.add(specifier.local.name) + } + }, + JSXOpeningElement(node) { + if (isTooltipTriggerOpening(node, tooltipTriggerNames)) { + const renderAttribute = getAttribute(node, 'render') + if (renderAttribute?.value?.type === 'JSXExpressionContainer') + collectReferencedIdentifiers(renderAttribute.value.expression, renderIdentifiers) + } + + if ( + getAttribute(node, 'className') || + getAttribute(node, 'class') || + getAttribute(node, 'style') + ) + candidates.push(node) + }, + 'Program:exit': function () { + for (const openingElement of candidates) { + if (!hasTruncation(openingElement, context.sourceCode, cssModuleBindings)) continue + if (isInsideTooltipTrigger(openingElement, tooltipTriggerNames)) continue + + const owningVariableName = getOwningVariableName(openingElement) + if (owningVariableName && renderIdentifiers.has(owningVariableName)) continue + + if (hasMultipleTextChildren(openingElement)) continue + if (hasTitledSingleChild(openingElement)) continue + + const titleAttribute = getAttribute(openingElement, 'title') + if (titleAttribute && !isEmptyTitleAttribute(titleAttribute)) continue + + const fixText = getTitleFixText(openingElement, context.sourceCode) + if (fixText === UNSAFE_TITLE_EXPRESSION) continue + + context.report({ + node: titleAttribute ?? openingElement.name, + messageId: titleAttribute ? 'emptyTitle' : 'missingTitle', + }) + } + }, + } + }, +} diff --git a/web/plugins/eslint/rules/require-title-for-truncated-text.test.js b/web/plugins/eslint/rules/require-title-for-truncated-text.test.js new file mode 100644 index 00000000000..80fb1cbb62e --- /dev/null +++ b/web/plugins/eslint/rules/require-title-for-truncated-text.test.js @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import tsParser from '@typescript-eslint/parser' +import { Linter } from 'eslint' +import { it } from 'vitest' +import rule from './require-title-for-truncated-text.js' +import './fixtures/truncation.module.css' + +const plugin = { + rules: { + 'require-title-for-truncated-text': rule, + }, +} + +function verifyAndFix(code, filename = 'test.tsx') { + const linter = new Linter({ cwd: resolve('..') }) + return linter.verifyAndFix( + code, + [ + { + files: ['**/*.tsx'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + plugins: { dify: plugin }, + rules: { 'dify/require-title-for-truncated-text': 'warn' }, + }, + ], + { filename }, + ) +} + +it('accepts an explicit title and line-clamp-none', () => { + const result = verifyAndFix(` + const name = 'Dify' + export const Example = () => <> + {name} + {name} + + `) + + assert.equal(result.messages.length, 0) + assert.equal(result.fixed, false) +}) + +it('accepts Dify UI TooltipTrigger children and render elements', () => { + const result = verifyAndFix(` + import { TooltipTrigger as Trigger } from '@langgenius/dify-ui/tooltip' + const name = 'Dify' + export const Example = () => <> + {name} + {name}} /> + + `) + + assert.equal(result.messages.length, 0) + assert.equal(result.fixed, false) +}) + +it('reports static classes, class helpers, constants, and inline styles without fixing', () => { + const code = ` + const truncatedClassName = cn('min-w-0', condition && 'md:truncate') + export const Example = ({ itemName, label }) => <> + {itemName} +

{label}

+
Description
+ + ` + const result = verifyAndFix(code) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 3) + assert.ok(result.messages.every((message) => message.severity === 1)) + assert.equal(result.output, code) +}) + +it('resolves truncation variables from the active lexical scope', () => { + const shadowedParameterCode = ` + const className = 'truncate' + const style = { textOverflow: 'ellipsis' } + export const Example = ({ className, style, label }) => <> + {label} + {label} + + ` + const shadowedParameterResult = verifyAndFix(shadowedParameterCode) + + assert.equal(shadowedParameterResult.fixed, false) + assert.equal(shadowedParameterResult.messages.length, 0) + assert.equal(shadowedParameterResult.output, shadowedParameterCode) + + const nestedBindingResult = verifyAndFix(` + const className = 'regular' + const style = { color: 'red' } + export const Example = ({ label }) => { + const className = 'truncate' + const style = { textOverflow: 'ellipsis' } + return <> + {label} + {label} + + } + `) + + assert.equal(nestedBindingResult.fixed, false) + assert.equal(nestedBindingResult.messages.length, 2) + assert.ok(nestedBindingResult.messages.every((message) => message.severity === 1)) +}) + +it('reports an empty title without replacing it', () => { + const code = ` + export const Example = ({ name }) => {name} + ` + const result = verifyAndFix(code) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 1) + assert.equal(result.messages[0].severity, 1) + assert.equal(result.messages[0].messageId, 'emptyTitle') + assert.equal(result.output, code) +}) + +it('reports custom components without fixing them', () => { + const code = ` + export const Example = ({ name }) => ( + {name} + ) + ` + const result = verifyAndFix(code) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 1) + assert.equal(result.messages[0].severity, 1) + assert.equal(result.output, code) +}) + +it('ignores expressions that may execute user code', () => { + const code = ` + export const Example = ({ item }) => { + let index = 0 + return <> + {item.name} + {getLabel()} + {index++} + + + } + ` + const result = verifyAndFix(code) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 0) + assert.equal(result.output, code) +}) + +it('reports a single nested text child or a text-bearing prop without fixing', () => { + const result = verifyAndFix(` + export const Example = ({ name, content }) => <> +
{name}
+ + + `) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 2) + assert.ok(result.messages.every((message) => message.severity === 1)) +}) + +it('checks Dify UI single text but ignores multiple text children', () => { + const filename = resolve('../packages/dify-ui/src/example.tsx') + const result = verifyAndFix( + ` + export const Example = ({ primary, secondary }) => <> + {primary} + {primary}{secondary} +
+ {primary} + {secondary} +
+ + `, + filename, + ) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 1) + assert.equal(result.messages[0].severity, 1) +}) + +it('does not count non-text conditional children as additional text', () => { + const result = verifyAndFix(` + export const Example = ({ primary, showOverlay }) => ( + + {primary} + {showOverlay && } + + ) + `) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 1) + assert.equal(result.messages[0].messageId, 'missingTitle') +}) + +it('resolves truncation classes imported from CSS modules', () => { + const filename = resolve('plugins/eslint/rules/fixtures/example.tsx') + const result = verifyAndFix( + ` + import styles from './truncation.module.css' + export const Example = ({ name }) => <> + {name} + {name} + {name} + {name} + {name} + {name} + + export const Shadowed = ({ styles, name }) => ( + {name} + ) + `, + filename, + ) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 2) + assert.ok(result.messages.every((message) => message.severity === 1)) +}) + +it('reports dynamic truncation class and style expressions without fixing', () => { + const code = ` + const regularClassName = 'regular' + const dynamicClassNames = ['regular'] + const classMap = { regular: true } + const regularStyle = { color: 'red' } + const styles = [regularStyle] + const tag = strings => strings[0] + export const Example = ({ condition, name }) => <> + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + + ` + const result = verifyAndFix(code) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 11) + assert.ok(result.messages.every((message) => message.messageId === 'missingTitle')) + assert.equal(result.output, code) +}) + +it('reports statically empty title forms without replacing them', () => { + const code = ` + export const Example = ({ name }) => <> + {name} + {name} + {name} + {name} + {name} + + ` + const result = verifyAndFix(code) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 5) + assert.ok(result.messages.every((message) => message.messageId === 'emptyTitle')) + assert.equal(result.output, code) +}) + +it('reports safe literal content but accepts a titled single child', () => { + const result = verifyAndFix(` + export const Example = ({ name }) => <> + {42} + {\`Dify\`} + {name} +
{name}
+ + `) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 3) + assert.deepEqual( + result.messages.map((message) => message.messageId), + ['missingTitle', 'missingTitle', 'emptyTitle'], + ) +}) + +it('accepts referenced TooltipTrigger render candidates', () => { + const result = verifyAndFix(` + import { TooltipTrigger as Trigger } from '@langgenius/dify-ui/tooltip' + export const Example = ({ condition, name }) => { + const primaryTrigger = {name} + const fallbackTrigger = {name} + return + } + `) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 0) +}) diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts index 4d400bba27f..6a887e110d0 100644 --- a/web/service/use-workflow.ts +++ b/web/service/use-workflow.ts @@ -1,3 +1,4 @@ +import type { UseQueryOptions } from '@tanstack/react-query' import type { CommonResponse } from '@/models/common' import type { FlowType } from '@/types/common' import type { @@ -18,8 +19,13 @@ import { appWorkflowQueryOptions, appWorkflowVersionsInfiniteQueryKey } from './ const NAME_SPACE = 'workflow' -export const useAppWorkflow = (appID: string) => { - return useQuery(appWorkflowQueryOptions(appID)) +type UseAppWorkflowOptions = Pick + +export const useAppWorkflow = (appID: string, options?: UseAppWorkflowOptions) => { + return useQuery({ + ...appWorkflowQueryOptions(appID), + ...options, + }) } const WorkflowRunHistoryKey = [NAME_SPACE, 'runHistory'] diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts index 521a500652d..d736ed5a428 100644 --- a/web/utils/app-redirection.spec.ts +++ b/web/utils/app-redirection.spec.ts @@ -14,20 +14,10 @@ describe('app-redirection', () => { * - App mode (workflow, advanced-chat, chat, completion, agent-chat) */ describe('getRedirectionPath', () => { - it('returns access point path when app access point permission is granted', () => { - const app = { - id: 'app-123', - mode: AppModeEnum.CHAT, - permission_keys: [AppACLPermission.AccessPoint], - } - const result = getRedirectionPath(app) - expect(result).toBe('/app/app-123/access-point') - }) - - it('returns apps list path when app ACL cannot access guarded pages or access point', () => { + it('returns access point path when app ACL cannot access guarded pages', () => { const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] } const result = getRedirectionPath(app) - expect(result).toBe('/apps') + expect(result).toBe('/app/app-123/access-point') }) it('returns workflow path for workflow mode when app ACL can access layout', () => { @@ -102,11 +92,7 @@ describe('app-redirection', () => { }) it('handles different app IDs', () => { - const app1 = { - id: 'abc-123', - mode: AppModeEnum.CHAT, - permission_keys: [AppACLPermission.AccessPoint], - } + const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT, permission_keys: [] } const app2 = { id: 'xyz-789', mode: AppModeEnum.WORKFLOW, @@ -143,7 +129,7 @@ describe('app-redirection', () => { const app = { id: 'app-123', mode: AppModeEnum.CHAT, - permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPoint], + permission_keys: [AppACLPermission.AccessConfig], } expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/access-point') @@ -187,12 +173,8 @@ describe('app-redirection', () => { /** * Tests that the redirection function is called with the correct path */ - it('calls redirection function with access point path when access point permission is granted', () => { - const app = { - id: 'app-123', - mode: AppModeEnum.CHAT, - permission_keys: [AppACLPermission.AccessPoint], - } + it('calls redirection function with access point path when app ACL cannot access guarded pages', () => { + const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] } const mockRedirect = vi.fn() getRedirection(app, mockRedirect) diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index d73a350b3f3..4bdfce9a266 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -34,9 +34,7 @@ export const getRedirectionPath = ( if (app.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy) return `/app/${app.id}/deploy` - if (appACLCapabilities.canAccessPoint) return `/app/${app.id}/access-point` - - return '/apps' + return `/app/${app.id}/access-point` } export const getRedirection = ( diff --git a/web/utils/permission.spec.ts b/web/utils/permission.spec.ts index 487c9c891b1..75dff3e7afe 100644 --- a/web/utils/permission.spec.ts +++ b/web/utils/permission.spec.ts @@ -50,15 +50,6 @@ describe('permission', () => { expect(releaseCapabilities.canDeploy).toBe(false) }) - it('keeps access point permission independent from other app ACL permissions', () => { - const accessPointCapabilities = getAppACLCapabilities([AppACLPermission.AccessPoint]) - const layoutCapabilities = getAppACLCapabilities([AppACLPermission.ViewLayout]) - - expect(accessPointCapabilities.canAccessPoint).toBe(true) - expect(accessPointCapabilities.canAccessLayout).toBe(false) - expect(layoutCapabilities.canAccessPoint).toBe(false) - }) - it('keeps monitor, tracing config, and log/annotation permissions independent', () => { const monitorCapabilities = getAppACLCapabilities([AppACLPermission.Monitor]) const tracingCapabilities = getAppACLCapabilities([AppACLPermission.TracingConfig]) @@ -118,7 +109,6 @@ describe('permission', () => { }) expect(capabilities.canViewLayout).toBe(true) - expect(capabilities.canAccessPoint).toBe(true) expect(capabilities.canTestAndRun).toBe(true) expect(capabilities.canEdit).toBe(true) expect(capabilities.canImportExportDSL).toBe(true) diff --git a/web/utils/permission.ts b/web/utils/permission.ts index 30b0b3e0cbe..063878b6607 100644 --- a/web/utils/permission.ts +++ b/web/utils/permission.ts @@ -2,7 +2,6 @@ import type { PermissionKey } from '@/models/access-control' export const AppACLPermission = { Preview: 'app.acl.preview', - AccessPoint: 'app.acl.access_point_manage', ViewLayout: 'app.acl.view_layout', TestAndRun: 'app.acl.test_and_run', Edit: 'app.acl.edit', @@ -39,7 +38,6 @@ export type ResourceMaintainerPermissionOptions = { } type AppACLCapabilities = { - canAccessPoint: boolean canViewLayout: boolean canTestAndRun: boolean canEdit: boolean @@ -137,11 +135,6 @@ export const getAppACLCapabilities = ( ) return { - canAccessPoint: hasResourcePermission( - permissionKeys, - AppACLPermission.AccessPoint, - hasMaintainerPermissions, - ), canViewLayout, canTestAndRun, canEdit,