diff --git a/.agents/skills/frontend-code-review/references/component-architecture.md b/.agents/skills/frontend-code-review/references/component-architecture.md index ffabbf4cd69..9eda6589fd0 100644 --- a/.agents/skills/frontend-code-review/references/component-architecture.md +++ b/.agents/skills/frontend-code-review/references/component-architecture.md @@ -61,7 +61,7 @@ Flag effects that: - Transform props/state for rendering. - Copy one state value into another representing the same concept. - Handle user actions that belong in event handlers. -- Reset state from props when a keyed reset, stable ID, or render-time derivation would work. +- Reset local state from props or visibility when derivation, a stable semantic identity, or the intended mounted owner already expresses the lifecycle. - Fetch data that belongs in framework APIs or TanStack Query. If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration. @@ -71,11 +71,24 @@ If an effect remains, it must synchronize with a named external system: browser Flag: - Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state. +- Per-session state held by a longer-lived visibility coordinator and cleared through an open-state Effect or a generated key when the primitive's mounted-content lifecycle already matches the intended state lifetime. +- A DOM field mirrored into competing prop, default, and React state sources when editing does not require those sources to synchronize. - Local state used to fake server data or generated contract fields. - UI state persisted to localStorage when it is live app state. - Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed. -Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source. +Review state lifetime before its storage mechanism. For a hidden surface, distinguish the +visibility coordinator from mounted content. State private to one mounted session belongs in that +content owner; promote it only when the draft must survive that content owner's unmount or another +owner coordinates it. A stable semantic identity key may create a new snapshot when the represented +identity changes; a generated key is not a routine reset command. + +Prefer render-time derivation. Keep true local state for user choices, transient input, controlled +popups, and feature UI state that has no server source. Submit-only DOM fields may remain +uncontrolled; use local controlled state when React must own the current value to drive rendering +or coordination. Observing change events or tracking a derived fact such as dirty state does not +require mirroring the field value. Do not flag controlled state by itself without a concrete +competing-source, stale-state, or ownership defect. ## Navigation diff --git a/.agents/skills/frontend-code-review/references/testing.md b/.agents/skills/frontend-code-review/references/testing.md index 2f81d10589e..3bda124c01b 100644 --- a/.agents/skills/frontend-code-review/references/testing.md +++ b/.agents/skills/frontend-code-review/references/testing.md @@ -9,6 +9,7 @@ Flag missing coverage when a change alters a reachable contract such as: - User interaction, navigation, form submission, validation, or permissions. - Query or mutation behavior, URL state, persistence, or one-shot signals. - Loading, error, empty, and recovery states that users can encounter. +- A hidden surface whose close-and-reopen behavior changes whether in-progress state resets or persists. - Accessibility-critical labels, keyboard flow, focus, disabled state, or overlay behavior. - A regression-prone business rule or bug fix that can be reproduced through a public boundary. diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 2b0e50f538d..b6599743227 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -9,10 +9,11 @@ Use this skill to route component architecture decisions to its bundled referenc ## First Decisions -| Question | Default | Promote only when | +| Question | Default | Choose differently when | | --- | --- | --- | | Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. | -| Who owns state and handlers? | The lowest visual owner that consumes them. | A parent coordinates one workflow or consistent snapshot. | +| Who owns state and handlers? | The lowest owner that consumes them and whose lifetime matches the state. | Another owner coordinates the value or it must survive the local owner's unmount. | +| Should React control a value? | Leave submit-only DOM fields uncontrolled. | The workflow must own the current value to drive rendering or coordination. | | Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. | | Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. | | Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. | @@ -24,12 +25,12 @@ Use this skill to route component architecture decisions to its bundled referenc - Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership]. - Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state]. - Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data]. -- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. +- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state. - Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime]. ## Workflow -1. Identify the behavior owner and the public contract being changed. +1. Identify the behavior owner, the required state lifetime, and the public contract being changed. 2. Read the nearby implementation, tests, and only the routed skill references. 3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them. 4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI. diff --git a/.agents/skills/how-to-write-component/references/interactions.md b/.agents/skills/how-to-write-component/references/interactions.md index 68233987b61..ba9315f981e 100644 --- a/.agents/skills/how-to-write-component/references/interactions.md +++ b/.agents/skills/how-to-write-component/references/interactions.md @@ -23,8 +23,10 @@ Read this document when a change involves application hotkeys, focus, dialogs, m - Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy. - Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment. - Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content. -- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers. -- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening. -- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior. +- Keep overlay open-state ownership separate from content-session ownership. A controlled root does not require controlled fields or root-owned drafts. +- Match transient state to the primitive's content mount lifecycle. State below an unmounting content boundary gets a fresh instance after unmount; intentionally kept-mounted content needs an explicit persistence or reset policy. +- Keep a controlled overlay root at its coordination owner so the primitive can complete exit transitions, focus restoration, and detached-handle behavior. Do not conditionally remove the root to reset content state, and use keys only for stable semantic identity. +- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition. +- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root. [overlay contract]: ../../../../packages/dify-ui/docs/overlays.md diff --git a/.agents/skills/how-to-write-component/references/ownership.md b/.agents/skills/how-to-write-component/references/ownership.md index d8da521aafc..886421ef394 100644 --- a/.agents/skills/how-to-write-component/references/ownership.md +++ b/.agents/skills/how-to-write-component/references/ownership.md @@ -12,8 +12,8 @@ Read this document when adding, moving, splitting, or refactoring React componen ## Component Ownership -- Put state, data access, loading, empty, error, and handlers in the lowest visual owner that uses them. -- Keep coordination in a parent only when it needs one consistent snapshot or coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors. +- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence. +- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors. - Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests. - Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept. - One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state. @@ -23,7 +23,8 @@ Read this document when adding, moving, splitting, or refactoring React componen ## Boundaries - State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions. -- The entrypoint owns route integration, provider wiring, close behavior, and mounting. Composition owners handle workflow branches; the closest visual owner handles section branches. +- The entrypoint owns route integration, provider wiring, placement, and open-state coordination. A content or session owner keeps state scoped to that mounted surface. +- Judge hook lifetime by the component that declares the hook and the primitive's mount contract, not only by where its rendered controls appear in JSX. - Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow. - Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary. - Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration. diff --git a/.agents/skills/how-to-write-component/references/runtime.md b/.agents/skills/how-to-write-component/references/runtime.md index 24554d895f5..42131944637 100644 --- a/.agents/skills/how-to-write-component/references/runtime.md +++ b/.agents/skills/how-to-write-component/references/runtime.md @@ -7,7 +7,7 @@ Read this document when a change introduces Effects, navigation side effects, me - Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values. - Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API. - Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query. -- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects. +- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset. ## Navigation diff --git a/.agents/skills/how-to-write-component/references/state.md b/.agents/skills/how-to-write-component/references/state.md index 0ede0a85e92..680d77fb8b9 100644 --- a/.agents/skills/how-to-write-component/references/state.md +++ b/.agents/skills/how-to-write-component/references/state.md @@ -9,10 +9,12 @@ Read this document when a change involves Jotai, form drafts, route identity, sh - Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels. - Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage. -## Forms +## Forms And Sessions -- Prefer uncontrolled Dify UI form and field controls when values are only read at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts. -- Promote form values to atoms only when another owner reacts to in-progress values, the draft must survive scoped unmounting, or several workflow steps edit the same draft. +- Keep form state in the narrowest owner whose lifetime matches the draft. A draft scoped to one mounted surface belongs to that content or session owner; a draft that must survive its current owner's unmount belongs to an explicit longer-lived feature owner. +- Prefer uncontrolled fields when values are only read at submit time. Use local controlled state only when React must own the current value to drive dependent UI or linked fields; track derived facts such as dirty state without mirroring the field value. Controlledness does not decide whether a draft is local or persisted. +- For query-backed defaults, establish the form session after the required defaults are available. `defaultValue` initializes the current mount; a stable semantic identity key may create a fresh snapshot when the represented identity changes. Do not use a generated key as a routine reset command. +- Promote drafts beyond the session only when another owner reacts to in-progress values, several workflow steps share one draft, or the draft must intentionally survive unmounting. Start with the lowest shared React owner; use feature-scoped atoms only when their coordination or persistence contract is needed. - Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission. ## Route And URL State 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 f2d3bace841..f8fa63a7fba 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 5ddb55aaa3f..241660291ab 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/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 b022187e14d..6e5fda0b0ba 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() @@ -1938,9 +1938,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 bf95b8650b1..fd0acf280b2 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -477,7 +477,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", @@ -493,7 +492,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", @@ -508,7 +506,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 579316e8fe8..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,90 +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 - system_features.knowledge_fs_enabled = dify_config.KNOWLEDGE_FS_ENABLED - @classmethod def _fulfill_trial_models_from_env(cls, quota_types: tuple[str, ...] | None = None) -> list[str]: allowed_quota_types = quota_types or ("PAID", "TRIAL") @@ -311,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..b459e8ec56f --- /dev/null +++ b/api/services/system_feature_service.py @@ -0,0 +1,287 @@ +"""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 + system_features.knowledge_fs_enabled = dify_config.KNOWLEDGE_FS_ENABLED + + @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 d7e8a2c1614..571819ff9a3 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 db538d78c13..80000b06f13 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_license.return_value.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 a94816949ac..665d5da681b 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 5d9148dee5a..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,8 +331,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 license_info = MagicMock() @@ -332,7 +348,7 @@ 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_license", + "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, @@ -349,8 +365,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 license_info = MagicMock() @@ -366,7 +382,7 @@ 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_license", + "controllers.console.workspace.members.SystemFeatureService.get_license", return_value=license_info, ) as mock_get_license, patch( @@ -386,8 +402,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 license_info = MagicMock() @@ -403,7 +419,7 @@ 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_license", + "controllers.console.workspace.members.SystemFeatureService.get_license", return_value=license_info, ) as mock_get_license, patch( @@ -422,8 +438,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 license_info = MagicMock() @@ -439,7 +455,7 @@ 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_license", + "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"), @@ -456,8 +472,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 license_info = MagicMock() @@ -473,7 +489,7 @@ 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_license", + "controllers.console.workspace.members.SystemFeatureService.get_license", return_value=license_info, ), patch( @@ -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 38601408790..63373290f36 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, @@ -35,68 +34,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 e163ebf4be5..17feffd4400 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 @@ -539,7 +539,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, @@ -566,7 +571,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: @@ -647,6 +657,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", @@ -671,6 +682,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", @@ -708,6 +720,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 c677d64c59c..890cc0068ed 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): @@ -3817,7 +3710,6 @@ class TestKnowledgeRetrievalRegression: "core.rag.retrieval.dataset_retrieval.DataPostProcessor", ContextRequiredPostProcessor, ), - _patched_retriever_session(), ): dataset_retrieval._multiple_retrieve_thread_safely( flask_app=flask_app, @@ -3854,21 +3746,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() @@ -3877,20 +3768,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] @@ -3902,21 +3792,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 == [] @@ -3959,10 +3848,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], @@ -4091,66 +3977,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() @@ -4300,8 +4171,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( @@ -4315,11 +4198,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"], [])), @@ -4342,11 +4220,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"), ) @@ -4358,23 +4236,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", @@ -4391,8 +4287,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", @@ -4401,7 +4297,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" @@ -4410,8 +4306,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", @@ -4420,7 +4316,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] @@ -4428,8 +4324,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", @@ -4439,22 +4335,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, @@ -4465,22 +4410,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) @@ -4552,6 +4493,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() @@ -4568,9 +4513,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( @@ -4586,10 +4531,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"), @@ -4627,17 +4572,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() @@ -4665,7 +4614,7 @@ class TestRetrieveCoverage: ), ) result = retrieval.retrieve( - MagicMock(), + self.orm_session, app_id="app-1", user_id="user-1", tenant_id="tenant-1", @@ -4695,7 +4644,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", @@ -4732,13 +4681,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", @@ -4777,7 +4726,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]), ): @@ -4788,7 +4737,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", @@ -4803,7 +4752,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, @@ -4813,7 +4768,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", @@ -4828,54 +4783,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( @@ -4893,10 +4879,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", @@ -4914,24 +4900,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, @@ -4942,14 +4935,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", @@ -4962,17 +4955,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", @@ -4987,9 +4983,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, @@ -5000,11 +4996,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", @@ -5012,19 +5008,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", @@ -5129,25 +5125,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", @@ -5159,9 +5158,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", @@ -5340,6 +5339,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() @@ -5359,7 +5363,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}) @@ -5367,62 +5371,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, @@ -5430,40 +5509,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, @@ -5474,10 +5544,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")] @@ -5485,16 +5569,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, @@ -5504,23 +5588,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", @@ -5528,9 +5641,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, @@ -5547,13 +5660,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, @@ -5569,8 +5680,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", @@ -5578,9 +5687,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, @@ -5600,22 +5709,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"), @@ -5625,11 +5742,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 7704308024c..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,47 +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_unpublished_roster_agent(): +def test_publish_validation_rejects_unpublished_roster_agent(sqlite_session: Session): binding = _binding(WorkflowNodeJobConfig()) binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT - session = Mock() - session.scalar.side_effect = [binding, None] + sqlite_session.add(binding) + sqlite_session.commit() with pytest.raises(WorkflowAgentNodeValidationError, match="unpublished roster agent"): 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_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( [ @@ -205,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( [ @@ -266,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": [ @@ -298,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", @@ -317,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).""" @@ -354,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( @@ -389,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( @@ -409,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( @@ -430,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( @@ -453,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( @@ -474,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( @@ -503,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"}])), ) @@ -529,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": [ @@ -620,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 = {} @@ -668,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 a17d285a7f9..8614264708c 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -117,7 +117,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, ): @@ -244,7 +244,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" @@ -289,7 +289,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( @@ -325,7 +325,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" @@ -353,7 +353,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): @@ -372,7 +372,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): @@ -386,7 +386,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" @@ -434,7 +434,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 @@ -475,7 +475,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: @@ -771,7 +771,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 { @@ -1674,7 +1674,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, ): @@ -1699,7 +1699,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 @@ -1750,7 +1750,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() @@ -1780,7 +1780,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 @@ -1816,7 +1816,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( @@ -1860,7 +1860,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( @@ -1895,7 +1895,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( @@ -1926,7 +1926,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" @@ -1973,7 +1973,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", @@ -2001,7 +2001,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( @@ -2033,7 +2033,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( @@ -2067,7 +2067,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" @@ -2107,7 +2107,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" @@ -2142,7 +2142,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" @@ -2195,7 +2195,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" @@ -2246,7 +2246,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" @@ -2280,7 +2280,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_change_email.py b/api/tests/unit_tests/services/test_feature_service_change_email.py deleted file mode 100644 index 9731d932ac6..00000000000 --- a/api/tests/unit_tests/services/test_feature_service_change_email.py +++ /dev/null @@ -1,17 +0,0 @@ -from collections.abc import Callable - -import pytest - -from services.feature_service import FeatureService - - -@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 = FeatureService.get_system_features() - - assert result.enable_change_email is enabled 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 75% 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 ecb053df260..f13813e22fb 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: @@ -18,6 +20,6 @@ def test_get_system_features_reads_knowledge_fs_flag( ) -> None: config_overrides(KNOWLEDGE_FS_ENABLED=enabled) - result = FeatureService.get_system_features() + result = SystemFeatureService.get_public_system_features() assert result.knowledge_fs_enabled is enabled 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 ed58e27086d..95e53bb51aa 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,13 +1,15 @@ +"""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 @@ -15,10 +17,10 @@ def test_system_feature_model_defaults_enable_learn_app(): @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 @@ -29,6 +31,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 48a0b0f4e8b..df0b4e36dba 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:marketplace-performance": "tsx ./scripts/run-cucumber.ts --full -- --tags @marketplace-performance", "e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down", "e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up", @@ -23,6 +24,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 b7689553430..2528c3e7c38 100644 --- a/lint.config.ts +++ b/lint.config.ts @@ -1117,6 +1117,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 5cbcedae409..c37c71c8795 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 } @@ -2222,9 +2219,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": { @@ -2618,9 +2612,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": { @@ -3845,11 +3836,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 @@ -4341,16 +4327,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 88fed453dd4..68ec273fbba 100644 --- a/package.json +++ b/package.json @@ -56,12 +56,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/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 5d02acdc1e0..2cd05f418f9 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -20,9 +20,9 @@ then read only the guide for the contract being changed. - Imports, exports, naming, public types, generics, and anatomy: [Public API authoring] - Button and icon-only action behavior: [Button contract] and [Icon Button contract] - Compound input behavior: [Input Group contract] -- Form structure and labels: [Forms] +- Form structure, labels, and value ownership: [Forms] - Picker choice and typed values: [Selection] -- Portals, layering, and floating-surface semantics: [Overlays] +- Portals, presence, layering, and floating-surface semantics: [Overlays] - Tailwind integration and radius mapping: [Styling] - Package test ownership and setup: [Testing and development] diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 5825f310a82..622b4f556b4 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -70,9 +70,9 @@ Upstream behavior remains owned by the [Base UI documentation]. | Guide | Scope | | ------------------------- | ------------------------------------------------------------------------------ | -| [Forms] | Native submit boundaries, fields, labels, grouped controls, and errors. | +| [Forms] | Native submit boundaries, value ownership, fields, labels, and errors. | | [Selection] | Typed values and choosing among segmented controls, pickers, and radio groups. | -| [Overlays] | Portals, root isolation, layering, trigger composition, and semantics. | +| [Overlays] | Portals, presence lifecycles, layering, trigger composition, and semantics. | | [Styling] | Tailwind CSS integration and the Figma radius mapping. | | [Public API authoring] | Subpath exports, naming, public types, generics, and private helpers. | | [Testing and development] | Package commands, test ownership, accessibility, and animation setup. | diff --git a/packages/dify-ui/docs/forms.md b/packages/dify-ui/docs/forms.md index f3d3f21c1fe..862b207ed74 100644 --- a/packages/dify-ui/docs/forms.md +++ b/packages/dify-ui/docs/forms.md @@ -16,6 +16,22 @@ remains correct when another form library owns submission and validation; do not Set [`Button`] submit buttons to `type="submit"` explicitly. Keep every other button inside a form at `type="button"`. +## Value and state ownership + +`Form` owns the submission and validation boundary described above, not each field's draft. + +Choose controlledness from source-of-truth needs, independently from where a draft is stored. +Prefer `defaultValue` when application React code does not need to own the current value. Use +`value` and change handlers when application rendering or coordination must own it while editing. +Listening to change events, tracking dirty state, and native or primitive validation do not by +themselves require controlled state. + +Application code owns the draft in the narrowest component whose lifetime matches it. A value can +be controlled locally without being lifted. An uncontrolled field can participate in a persisted +workflow when that workflow captures its value at an explicit persistence boundary. The +surrounding surface defines its mount lifecycle; owner placement determines whether draft state +lives inside or outside that lifecycle. + ## Fields and labels Use `Field` when a control needs a shared name, label, validation, description, or error state. A @@ -61,9 +77,9 @@ option with `FieldItem` and give it its own label: Every radio belongs to a `RadioGroup`. Use `FieldsetLegend` to name the group and `FieldLabel` to name each option; do not render a standalone `Radio`. -Keep form state, schemas, server validation, and reset behavior outside these primitives. Pass -their observable state through the public field and control props instead of replacing the -semantic structure. +Keep form state, schemas, server validation, and reset behavior outside the primitive internals, +in the nearest application owner with the required lifetime. Pass observable state through the +public field and control props instead of replacing the semantic structure. [Base UI Slider anatomy]: https://base-ui.com/react/components/slider#anatomy [Base UI forms handbook]: https://base-ui.com/react/handbook/forms diff --git a/packages/dify-ui/docs/overlays.md b/packages/dify-ui/docs/overlays.md index 422ce4621f5..1274ce1b274 100644 --- a/packages/dify-ui/docs/overlays.md +++ b/packages/dify-ui/docs/overlays.md @@ -10,6 +10,25 @@ Floating surfaces render through [Base UI Portal] into `document.body`. Convenie such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portals internally; primitives with explicit anatomy expose the constituent portal and content parts. +## Mounting and state lifetime + +An overlay root's React lifetime, its open state, and its portal subtree's presence are separate. +Presence is part of each primitive's contract. Convenience content follows its primitive's +default mount behavior; for example, `DialogContent` owns a [`Dialog.Portal`] whose subtree mounts +when the dialog opens and unmounts after any close transition completes. The `Dialog` root may +remain mounted and controlled independently of that content lifetime. Removing the controlled +root with the same condition that closes it bypasses the primitive's closing lifecycle. + +Application code can use an unmounting content subtree as the owner of state scoped to one mounted +content session. State that must survive the subtree's unmount belongs to an explicit longer-lived +feature owner. +Unmounting resets only DOM and component state owned inside that subtree; state declared by an +ancestor or external store survives. Portal placement alone is not a reset boundary. +Consumers using explicit anatomy may opt into `keepMounted` where that portal supports it; they +must then define which state persists and which state resets instead of relying on a remount. +Check the selected primitive's API rather than assuming every overlay portal has the same presence +options. + The host must establish an isolated stacking context at its application root: ```tsx @@ -58,6 +77,7 @@ spacing unless its API documents a measured exception. [Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals [MDN `isolation`]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/isolation +[`Dialog.Portal`]: https://base-ui.com/react/components/dialog#portal [`Popover`]: https://base-ui.com/react/components/popover [`PreviewCard`]: https://base-ui.com/react/components/preview-card [`Tooltip`]: https://base-ui.com/react/components/tooltip 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 114f415e7a1..429790217f5 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 @@ -79,8 +79,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 @@ -109,8 +109,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 @@ -157,14 +157,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 @@ -172,8 +172,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 @@ -184,8 +184,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 @@ -223,14 +223,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 @@ -307,11 +307,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 @@ -343,8 +343,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 @@ -370,20 +370,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 @@ -403,8 +403,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 @@ -415,8 +415,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 @@ -424,11 +424,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 @@ -436,14 +436,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 @@ -457,17 +457,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 @@ -487,8 +487,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 @@ -532,8 +532,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 @@ -547,8 +547,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 @@ -559,11 +559,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 @@ -604,8 +604,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 @@ -622,11 +622,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 @@ -644,10 +644,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) @@ -659,13 +659,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 @@ -674,73 +674,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: @@ -767,13 +767,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 @@ -788,14 +788,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 @@ -810,19 +810,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: @@ -852,7 +852,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 @@ -864,22 +864,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: @@ -888,7 +888,7 @@ importers: version: 1.15.0 zod: specifier: 'catalog:' - version: 4.4.3 + version: 4.5.4 devDependencies: '@dify/tsconfig': specifier: workspace:* @@ -904,25 +904,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) @@ -931,7 +931,7 @@ importers: version: 5.0.0 hono: specifier: 'catalog:' - version: 4.13.3 + version: 4.13.5 devDependencies: '@dify/tsconfig': specifier: workspace:* @@ -944,13 +944,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: @@ -966,7 +966,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 @@ -978,25 +978,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) @@ -1014,10 +1014,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) @@ -1035,7 +1035,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 @@ -1044,13 +1044,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) @@ -1062,7 +1062,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: @@ -1077,19 +1077,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: @@ -1108,10 +1108,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/tsconfig: {} @@ -1134,22 +1134,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) @@ -1188,7 +1188,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) @@ -1203,13 +1203,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) @@ -1218,10 +1218,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) @@ -1230,7 +1230,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) @@ -1281,13 +1281,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 @@ -1311,19 +1311,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 @@ -1332,19 +1332,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 @@ -1359,13 +1359,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) pinyin-pro: specifier: 'catalog:' version: 3.29.3 @@ -1374,7 +1374,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 @@ -1416,7 +1416,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) @@ -1428,13 +1428,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 @@ -1446,7 +1446,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))) @@ -1456,7 +1456,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 @@ -1486,28 +1486,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 @@ -1516,7 +1516,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) @@ -1555,13 +1555,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) @@ -1573,7 +1573,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 @@ -1585,13 +1585,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' @@ -1600,19 +1600,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) @@ -1629,50 +1629,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==} @@ -1700,8 +1697,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==} @@ -1949,6 +1946,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==} @@ -2339,160 +2339,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] @@ -2731,8 +2731,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 @@ -2842,57 +2842,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] @@ -2963,8 +2963,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] @@ -2975,8 +2975,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] @@ -2987,8 +2987,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] @@ -2999,8 +2999,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] @@ -3011,8 +3011,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] @@ -3023,8 +3023,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] @@ -3035,8 +3035,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] @@ -3048,8 +3048,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] @@ -3062,8 +3062,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] @@ -3076,8 +3076,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] @@ -3090,8 +3090,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] @@ -3104,8 +3104,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] @@ -3118,8 +3118,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] @@ -3132,8 +3132,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] @@ -3146,8 +3146,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] @@ -3159,8 +3159,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] @@ -3176,8 +3176,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] @@ -3188,8 +3188,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] @@ -3200,8 +3200,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] @@ -3213,12 +3213,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] @@ -3774,71 +3774,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': @@ -3923,7 +3923,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: '*' @@ -4154,8 +4154,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 @@ -4174,8 +4174,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==} @@ -4193,8 +4193,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 @@ -4228,8 +4228,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 @@ -4458,6 +4458,9 @@ packages: '@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==} @@ -4502,55 +4505,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': @@ -4714,8 +4717,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 @@ -5196,6 +5199,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==} @@ -5221,6 +5229,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==} @@ -5284,6 +5297,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} @@ -5433,6 +5449,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==} @@ -5461,8 +5481,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==} @@ -5729,6 +5750,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'} @@ -5819,6 +5844,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==} @@ -5912,6 +5940,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'} @@ -6034,8 +6065,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 @@ -6128,8 +6159,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: @@ -6227,7 +6258,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 @@ -6259,20 +6290,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: '*' @@ -6336,8 +6367,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==} @@ -6369,8 +6400,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: @@ -6392,8 +6423,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: @@ -6450,8 +6481,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: @@ -6511,8 +6542,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: @@ -6679,8 +6710,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 @@ -6716,14 +6747,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'} @@ -6774,8 +6809,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 @@ -6785,8 +6820,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: @@ -7004,8 +7039,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==} @@ -7118,15 +7153,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==} @@ -7327,8 +7362,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: @@ -7359,7 +7394,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: @@ -7367,9 +7406,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 @@ -7377,9 +7416,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 @@ -7387,9 +7426,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 @@ -7397,9 +7436,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 @@ -7407,19 +7446,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 @@ -7427,9 +7456,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 @@ -7437,9 +7466,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 @@ -7447,10 +7487,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 @@ -7458,31 +7498,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 @@ -7491,14 +7520,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: @@ -7514,8 +7543,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 @@ -7574,6 +7603,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==} @@ -7589,8 +7622,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: @@ -7648,6 +7681,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'} @@ -7739,6 +7775,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==} @@ -7798,6 +7838,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'} @@ -7833,8 +7877,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: @@ -8048,8 +8092,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==} @@ -8148,8 +8192,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': '*' @@ -8169,8 +8213,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: @@ -8196,8 +8240,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: @@ -8285,8 +8329,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 @@ -8472,11 +8516,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: @@ -8552,8 +8596,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 @@ -8663,6 +8707,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==} @@ -9023,6 +9073,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==} @@ -9073,28 +9126,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 @@ -9104,7 +9150,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 @@ -9112,52 +9158,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 @@ -9195,9 +9240,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 @@ -9360,12 +9405,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' @@ -9567,6 +9612,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 @@ -9587,7 +9637,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 @@ -9595,7 +9645,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 @@ -9680,100 +9730,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: @@ -9931,9 +9981,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: @@ -9997,119 +10047,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' @@ -10370,9 +10420,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: @@ -10464,30 +10514,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': @@ -10560,11 +10610,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' @@ -10573,97 +10623,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': @@ -10676,29 +10726,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 @@ -11057,83 +11107,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 @@ -11151,21 +11201,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 @@ -11176,54 +11226,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': {} @@ -11231,18 +11281,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) @@ -11255,30 +11305,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: @@ -11289,15 +11339,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) @@ -11320,19 +11370,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: @@ -11403,19 +11453,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: @@ -11433,7 +11483,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: @@ -11450,9 +11500,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)': @@ -11494,7 +11544,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 @@ -11748,6 +11798,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/node@26.4.0': + dependencies: + undici-types: 8.3.0 + '@types/normalize-package-data@2.4.4': {} '@types/papaparse@5.5.2': @@ -11778,7 +11832,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.4.0 '@types/yauzl@2.10.3': dependencies: @@ -11787,56 +11841,56 @@ snapshots: '@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 @@ -11846,20 +11900,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': @@ -11932,13 +11986,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: @@ -11956,24 +12010,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 @@ -11984,31 +12038,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 @@ -12016,40 +12070,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 @@ -12057,16 +12111,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 @@ -12086,9 +12140,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: @@ -12107,21 +12161,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: @@ -12161,7 +12215,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 @@ -12182,11 +12236,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 @@ -12207,7 +12261,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 @@ -12438,6 +12492,8 @@ snapshots: baseline-browser-mapping@2.10.40: {} + baseline-browser-mapping@2.11.20: {} + birecord@0.1.2: {} birpc@4.0.0: {} @@ -12467,13 +12523,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: @@ -12527,6 +12591,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001810: {} + canvas@3.2.3: dependencies: node-addon-api: 7.1.1 @@ -12667,6 +12733,8 @@ snapshots: comment-parser@1.4.7: {} + comment-parser@1.4.8: {} + compare-versions@6.1.1: {} concurrently@10.0.5: @@ -12690,9 +12758,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: @@ -12977,6 +13045,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: {} @@ -13066,6 +13139,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): @@ -13146,6 +13221,8 @@ snapshots: es-toolkit@1.51.0: {} + es-toolkit@1.52.0: {} + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -13183,32 +13260,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)) @@ -13220,37 +13297,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 @@ -13258,7 +13335,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 @@ -13270,27 +13347,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) @@ -13305,13 +13382,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 @@ -13319,28 +13396,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 @@ -13350,94 +13427,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 @@ -13445,48 +13522,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 @@ -13497,14 +13574,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 @@ -13519,9 +13596,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 @@ -13637,9 +13714,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: {} @@ -13664,18 +13741,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 @@ -13720,7 +13798,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 @@ -13751,7 +13829,7 @@ snapshots: globals@15.15.0: {} - globals@17.7.0: {} + globals@17.11.0: {} globrex@0.1.2: {} @@ -13766,9 +13844,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 @@ -13913,7 +13991,7 @@ snapshots: hex-rgb@4.3.0: {} - hono@4.13.3: {} + hono@4.13.5: {} hosted-git-info@9.0.3: dependencies: @@ -13969,7 +14047,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.8: {} image-size@2.0.2: {} @@ -14073,20 +14151,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 @@ -14107,12 +14185,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: {} @@ -14155,16 +14235,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 @@ -14177,7 +14257,7 @@ snapshots: kolorist@1.8.0: {} - ky@2.0.2: {} + ky@2.1.0: {} launch-ide@1.4.5: dependencies: @@ -14342,7 +14422,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loro-crdt@1.14.1: {} + loro-crdt@1.15.1: {} loupe@3.2.1: {} @@ -14583,14 +14663,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 @@ -14915,9 +14995,9 @@ 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 @@ -14926,16 +15006,16 @@ snapshots: 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' @@ -14951,7 +15031,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: @@ -14967,12 +15049,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: {} @@ -15023,6 +15105,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: @@ -15070,29 +15161,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: @@ -15138,7 +15229,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: @@ -15161,9 +15252,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: @@ -15186,7 +15277,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: @@ -15197,7 +15288,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 @@ -15219,9 +15310,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 @@ -15243,7 +15334,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: @@ -15261,6 +15352,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 @@ -15350,6 +15443,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + pinyin-pro@3.29.3: {} pkg-types@1.3.1: @@ -15412,6 +15507,8 @@ snapshots: powershell-utils@0.2.0: {} + powershell-utils@0.2.1: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -15457,7 +15554,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' @@ -15753,7 +15850,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: {} @@ -15843,37 +15940,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 @@ -15885,14 +15982,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 @@ -15920,7 +16017,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: @@ -15988,7 +16085,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) @@ -16009,19 +16106,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 @@ -16030,7 +16126,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 @@ -16184,8 +16280,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: {} @@ -16195,11 +16291,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: @@ -16257,7 +16353,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.12: + tsx@4.23.13: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -16382,7 +16478,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): @@ -16391,6 +16487,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 @@ -16456,21 +16558,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 @@ -16488,9 +16590,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 @@ -16499,47 +16601,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 @@ -16579,26 +16681,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 @@ -16638,26 +16740,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) @@ -16666,12 +16768,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 @@ -16682,27 +16784,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 @@ -16713,20 +16815,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 @@ -16873,6 +16975,8 @@ snapshots: zod@4.4.3: {} + zod@4.5.4: {} + zrender@6.1.0: dependencies: tslib: 2.3.0 @@ -16899,8 +17003,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' @@ -16923,7 +17027,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' @@ -16933,7 +17037,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' @@ -16949,16 +17053,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' @@ -16971,9 +17075,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' @@ -17000,7 +17104,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' @@ -17011,21 +17115,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' @@ -17034,34 +17138,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' @@ -17076,17 +17180,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' @@ -17099,6 +17203,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 a2538f668af..28b40c1b9cc 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 @@ -87,7 +87,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 @@ -97,7 +97,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 @@ -113,16 +113,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 @@ -135,9 +135,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 @@ -163,8 +163,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 @@ -175,7 +175,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 @@ -184,46 +184,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 @@ -238,17 +238,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 @@ -262,7 +262,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/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 46a56fb684e..adb020b86b7 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,5 +1,5 @@ import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' @@ -1291,6 +1291,21 @@ describe('AppCard', () => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) }) + + it('should hide open in explore for SSO-restricted apps', async () => { + mockWebappAuthEnabled = true + const user = userEvent.setup() + const ssoApp = createMockApp({ access_mode: AccessMode.EXTERNAL_MEMBERS }) + + render() + + await user.click(getOperationsTrigger()) + const menu = await screen.findByRole('menu') + + expect( + within(menu).queryByRole('menuitem', { name: 'app.openInExplore' }), + ).not.toBeInTheDocument() + }) }) describe('Workflow Export with Environment Variables', () => { diff --git a/web/app/components/apps/app-card/interactions.tsx b/web/app/components/apps/app-card/interactions.tsx index 137e04c0bdb..0bdb03c2101 100644 --- a/web/app/components/apps/app-card/interactions.tsx +++ b/web/app/components/apps/app-card/interactions.tsx @@ -55,6 +55,7 @@ import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' +import { AccessMode } from '@/models/access-control' import dynamic from '@/next/dynamic' import { useRouter } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' @@ -136,6 +137,7 @@ function AppCardOperationsMenuItems({ const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(app) && !app.workflow?.id const shouldShowOpenInExploreOption = !app.has_draft_trigger && + app.access_mode !== AccessMode.EXTERNAL_MEMBERS && (needsPublishBeforeExplore || !systemFeatures.webapp_auth.enabled || (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) 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 21567090075..2b1e39a7075 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 @@ -246,7 +246,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 ?? [])} > + + + + ) +} + const createFormInput = (overrides: Partial = {}): FormInputItem => ({ type: InputVarType.paragraph, output_variable_name: 'user_name', @@ -251,6 +272,70 @@ describe('human-input/delivery-method/test-email-sender', () => { expect(handleOpenChange).toHaveBeenCalledWith(false) }) + it('should start a fresh session after closing and reopening', async () => { + const user = userEvent.setup() + const { requests } = setupFetch() + renderWithProviders() + + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) + expect( + await screen.findByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done'), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + await user.click(screen.getByRole('button', { name: 'Open test email sender' })) + + expect( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ).toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done'), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) + await waitFor(() => { + expect( + requests.filter( + (request) => request.method === 'POST' && request.url.endsWith('/delivery-test'), + ), + ).toHaveLength(2) + }) + }) + + it('should stay open when clicking outside the dialog', async () => { + const user = userEvent.setup() + const handleOpenChange = vi.fn() + + renderWithProviders( + , + ) + + await user.click(document.body) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(handleOpenChange).not.toHaveBeenCalled() + }) + it('should submit variables referenced by dynamic select option sources', async () => { const user = userEvent.setup() const { requests } = setupFetch() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx index e9f2067d338..2a4f8614eec 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx @@ -3,13 +3,13 @@ import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Input } from '@langgenius/dify-ui/input' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' import { RiBugLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useId, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' import { userProfileQueryOptions } from '@/features/account-profile/client' import MailBodyInput from './mail-body-input' import Recipient from './recipient' @@ -34,6 +34,7 @@ const EmailConfigureModal = ({ availableNodes = [], }: EmailConfigureModalProps) => { const { t } = useTranslation() + const subjectId = useId() const { data: email } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.email, @@ -116,15 +117,19 @@ const EmailConfigureModal = ({
-
+
+ setSubject(e.target.value)} + onValueChange={setSubject} placeholder={t( ($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`], { ns: 'workflow' }, diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index 86f772b15e7..5d730e134cc 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -45,6 +45,8 @@ type EmailSenderModalProps = { availableNodes?: Node[] } +type EmailSenderContentProps = Omit + const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { const targetVar = list.find((item) => item.nodeId === valueSelector[0]) if (!targetVar) return undefined @@ -117,10 +119,9 @@ const formatEmailSenderInputs = ( } } -const EmailSenderModal = ({ +const EmailSenderContent = ({ nodeId, deliveryId, - open, onOpenChange, jumpToEmailConfigModal, config, @@ -128,7 +129,7 @@ const EmailSenderModal = ({ formInputs, nodesOutputVars = [], availableNodes = [], -}: EmailSenderModalProps) => { +}: EmailSenderContentProps) => { const { t } = useTranslation() const { data: userProfileEmail } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -258,142 +259,49 @@ const EmailSenderModal = ({ if (done) { return ( - - -
- - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.done`], { ns: 'workflow' })} - - {debugEnabled && ( -
- $[`${i18nPrefix}.deliveryMethod.emailSender.debugDone`]} - ns="workflow" - components={{ - email: , - }} - values={{ email: userProfileEmail }} - /> -
- )} - {!debugEnabled && onlyWholeTeam && ( -
- $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`]} - ns="workflow" - components={{ - team: , - }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
- )} - {!debugEnabled && onlySpecificUsers && ( -
- {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`], { - ns: 'workflow', - })} -
- )} - {!debugEnabled && combinedRecipients && ( -
- $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`]} - ns="workflow" - components={{ - team: , - }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
- )} -
- {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( -
- +
+ + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.done`], { ns: 'workflow' })} + + {debugEnabled && ( +
+ $[`${i18nPrefix}.deliveryMethod.emailSender.debugDone`]} + ns="workflow" + components={{ + email: , + }} + values={{ email: userProfileEmail }} />
)} -
- -
- -
- ) - } - - return ( - - - $['operation.close'], { ns: 'common' })} - size="lg" - className="absolute inset-e-6 top-6" - > - - - } - /> -
- - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.title`], { ns: 'workflow' })} - - {debugEnabled && ( - <> -
- {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`], { - ns: 'workflow', - })} -
-
- $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`]} - ns="workflow" - components={{ - email: , - }} - values={{ email: userProfileEmail }} - /> -
- - )} {!debugEnabled && onlyWholeTeam && ( -
+
$[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`]} ns="workflow" components={{ - team: , + team: , }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} />
)} {!debugEnabled && onlySpecificUsers && ( -
- {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`], { +
+ {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`], { ns: 'workflow', })}
)} {!debugEnabled && combinedRecipients && ( -
+
$[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`]} ns="workflow" components={{ - team: , + team: , }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} /> @@ -401,94 +309,193 @@ const EmailSenderModal = ({ )}
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( - <> -
- -
-
- $[`${i18nPrefix}.deliveryMethod.emailSender.tip`]} - ns="workflow" - components={{ - strong: ( -
- - )} - {/* vars */} - {generatedInputs.length > 0 && ( - <> -
- -
-
- -
- {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.varsTip`], { - ns: 'workflow', - })} -
- {!collapsed && ( -
- {generatedInputs.map((variable, index) => ( -
- handleValueChange(variable.variable, v)} - /> -
- ))} -
- )} -
- +
+ +
)}
- -
+ + ) + } + + return ( + <> + $['operation.close'], { ns: 'common' })} + size="lg" + className="absolute inset-e-6 top-6" + > + + + } + /> +
+ + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.title`], { ns: 'workflow' })} + + {debugEnabled && ( + <> +
+ {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`], { + ns: 'workflow', + })} +
+
+ $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`]} + ns="workflow" + components={{ + email: , + }} + values={{ email: userProfileEmail }} + /> +
+ + )} + {!debugEnabled && onlyWholeTeam && ( +
+ $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`]} + ns="workflow" + components={{ + team: , + }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
+ )} + {!debugEnabled && onlySpecificUsers && ( +
+ {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`], { + ns: 'workflow', + })} +
+ )} + {!debugEnabled && combinedRecipients && ( +
+ $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`]} + ns="workflow" + components={{ + team: , + }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
+ )} +
+ {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( + <> +
+ +
+
+ $[`${i18nPrefix}.deliveryMethod.emailSender.tip`]} + ns="workflow" + components={{ + strong: ( +
+ + )} + {/* vars */} + {generatedInputs.length > 0 && ( + <> +
+ +
+
+ +
+ {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.varsTip`], { + ns: 'workflow', + })} +
+ {!collapsed && ( +
+ {generatedInputs.map((variable, index) => ( +
+ handleValueChange(variable.variable, v)} + /> +
+ ))} +
+ )} +
+ + )} +
+ + +
+ + ) +} + +const EmailSenderModal = ({ open, onOpenChange, ...props }: EmailSenderModalProps) => { + return ( + + + ) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx index edf8dbe62e5..a0c53bbdbfd 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx @@ -204,7 +204,12 @@ describe('TriggerSchedulePanel', () => { renderPanel('node-3', createData({ mode: 'cron' })) - fireEvent.change(screen.getByDisplayValue('0 0 * * *'), { target: { value: '*/5 * * * *' } }) + fireEvent.change( + screen.getByRole('textbox', { + name: 'workflow.nodes.triggerSchedule.cronExpression', + }), + { target: { value: '*/5 * * * *' } }, + ) expect(handleCronExpressionChange).toHaveBeenCalledWith('*/5 * * * *') }) @@ -255,7 +260,11 @@ describe('TriggerSchedulePanel', () => { panelProps={panelProps} />, ) - expect(screen.getByRole('textbox')).toHaveValue('') + expect( + screen.getByRole('textbox', { + name: 'workflow.nodes.triggerSchedule.cronExpression', + }), + ).toHaveValue('') }) it('should render the hourly minute selector when the frequency is hourly', async () => { diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx index e12b13815a5..838e4d740e5 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -1,10 +1,10 @@ import type { FC } from 'react' import type { ScheduleTriggerNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' +import { Input } from '@langgenius/dify-ui/input' import * as React from 'react' import { useTranslation } from 'react-i18next' import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' -import Input from '@/app/components/base/input' import Field from '@/app/components/workflow/nodes/_base/components/field' import FrequencySelector from './components/frequency-selector' import ModeToggle from './components/mode-toggle' @@ -18,6 +18,7 @@ const i18nPrefix = 'nodes.triggerSchedule' const Panel: FC> = ({ id, data }) => { const { t } = useTranslation() + const cronExpressionId = React.useId() const { inputs, setInputs, @@ -112,12 +113,16 @@ const Panel: FC> = ({ id, data }) => { {inputs.mode === 'cron' && (
-
) : ( diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx index a35ac10f899..7ef45fc1e97 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' import { AgentPromptSlashMenu } from '../slash' describe('AgentPromptSlashMenu', () => { diff --git a/web/features/agent-v2/roster/__tests__/page.spec.tsx b/web/features/agent-v2/roster/__tests__/page.spec.tsx index bae86e97646..e1ac7fa07eb 100644 --- a/web/features/agent-v2/roster/__tests__/page.spec.tsx +++ b/web/features/agent-v2/roster/__tests__/page.spec.tsx @@ -129,7 +129,10 @@ describe('RosterPage', () => { it('uses the localized roster title for the page heading', () => { render() - expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toHaveAttribute( + 'title', + 'agentV2.roster.title', + ) expect(screen.getByRole('region', { name: 'agentV2.roster.title' })).toBeInTheDocument() }) diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index ad0bdf8d1cb..f7f41181de1 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -127,7 +127,7 @@ describe('AgentRosterList', () => { expect(screen.queryByText('agent')).not.toBeInTheDocument() }) - it('exposes each agent card with the agent name', () => { + it('exposes each agent card with its name, draft status, and description', () => { renderList([createAgent()]) const list = screen.getByRole('list') @@ -136,7 +136,9 @@ describe('AgentRosterList', () => { expect(card.parentElement).toBe(list) expect(cardLink).toHaveAttribute('href', '/agents/agent-1/configure') - expect(cardLink).toHaveAccessibleDescription('Find and summarize market materials.') + expect(cardLink).toHaveAccessibleDescription( + 'agentV2.roster.usageStatus.draft Find and summarize market materials.', + ) }) it('uses the Figma-aligned card title and role typography', () => { diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 68847ce4c94..44625a88fd8 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -204,6 +204,7 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const { formatTime } = useTimestamp() const nameId = useId() const descriptionId = useId() + const draftStatusId = useId() const [activeDialog, setActiveDialog] = useState<'delete' | 'duplicate' | 'edit' | null>(null) const { exportAppDsl, isExporting } = useExportAppDsl() const updatedAt = @@ -217,6 +218,12 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const publishedReferences = agent.published_references ?? [] const hasPublishedReferences = publishedReferences.length > 0 const isDraft = agent.active_config_is_published !== true + const accessibleDescriptionIds = [ + isDraft ? draftStatusId : '', + agent.description ? descriptionId : '', + ] + .filter(Boolean) + .join(' ') const parsedIconType = zAgentIconType.safeParse(agent.icon_type).data const imageUrl = parsedIconType === 'image' @@ -265,7 +272,7 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
@@ -294,7 +301,10 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { {isDraft && (
-
+
{t(($) => $['roster.usageStatus.draft'])}
diff --git a/web/features/agent-v2/roster/page.tsx b/web/features/agent-v2/roster/page.tsx index c36f3c3fd11..c7f13199c7c 100644 --- a/web/features/agent-v2/roster/page.tsx +++ b/web/features/agent-v2/roster/page.tsx @@ -112,6 +112,7 @@ export default function RosterPage() {

{pageTitle}

diff --git a/web/features/home/continue-work/__tests__/item.spec.tsx b/web/features/home/continue-work/__tests__/item.spec.tsx index 37b74a4b944..ee61d10d6ce 100644 --- a/web/features/home/continue-work/__tests__/item.spec.tsx +++ b/web/features/home/continue-work/__tests__/item.spec.tsx @@ -168,15 +168,10 @@ describe('ContinueWorkItem', () => { ) }) - it('should fall back to access point when RBAC is disabled for an access-config app with access point permission', () => { - renderItem( - createApp({ - permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPoint], - }), - { - rbac_enabled: false, - }, - ) + it('should fall back to access point when RBAC is disabled for an access-config-only app', () => { + renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { + rbac_enabled: false, + }) expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute( 'href', diff --git a/web/features/skills/__tests__/detail-page.spec.tsx b/web/features/skills/__tests__/detail-page.spec.tsx index 1ba179b29b0..58293182aed 100644 --- a/web/features/skills/__tests__/detail-page.spec.tsx +++ b/web/features/skills/__tests__/detail-page.spec.tsx @@ -12,7 +12,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import userEvent from '@testing-library/user-event' import copy from 'copy-to-clipboard' import { StrictMode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import SkillDetailPage from '../detail-page' diff --git a/web/features/skills/__tests__/page.spec.tsx b/web/features/skills/__tests__/page.spec.tsx index 12c1071a752..07ec0652ea3 100644 --- a/web/features/skills/__tests__/page.spec.tsx +++ b/web/features/skills/__tests__/page.spec.tsx @@ -9,7 +9,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor, within } 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 SkillsPage from '../page' type SkillsInfiniteOptions = { @@ -237,14 +237,13 @@ function createAgentReference( } } -function renderSkillsPage() { - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { retry: false }, - queries: { retry: false }, - }, +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, }) +} +function renderSkillsPage(queryClient = createTestQueryClient()) { return render( @@ -310,12 +309,97 @@ describe('SkillsPage', () => { }) }) + it('exposes loading as a status without presenting skeletons as skill items', () => { + mocks.skillsQueryOptions.mockImplementation((options) => ({ + queryKey: ['skills-pending', options], + queryFn: () => new Promise(() => {}), + getNextPageParam: options.getNextPageParam, + initialPageParam: options.initialPageParam, + })) + + renderSkillsPage() + + const skillRegion = screen.getByRole('region', { + name: 'skill.skillManagement.listLabel', + }) + expect(skillRegion).toHaveAttribute('aria-busy', 'true') + expect(within(skillRegion).getByRole('status')).toHaveTextContent('common.loading') + expect(within(skillRegion).queryByRole('list')).not.toBeInTheDocument() + expect(within(skillRegion).queryByRole('listitem')).not.toBeInTheDocument() + }) + + it('exposes a failed skill request as an alert without a stale list', async () => { + mocks.skillsQueryOptions.mockImplementation((options) => ({ + queryKey: ['skills-error', options], + queryFn: () => Promise.reject(new Error('skills request failed')), + getNextPageParam: options.getNextPageParam, + initialPageParam: options.initialPageParam, + })) + + renderSkillsPage() + + const skillRegion = screen.getByRole('region', { + name: 'skill.skillManagement.listLabel', + }) + expect(await within(skillRegion).findByRole('alert')).toHaveTextContent( + 'skill.skillManagement.loadingError', + ) + expect( + within(skillRegion).getByRole('button', { name: 'common.operation.retry' }), + ).toBeInTheDocument() + expect(within(skillRegion).queryByRole('list')).not.toBeInTheDocument() + }) + + it('replaces a cached empty state with a retryable error when refetch fails', async () => { + const user = userEvent.setup() + const queryKey = ['skills-cached-empty-refetch-error'] + let requestCount = 0 + mocks.skillsQueryOptions.mockImplementation((options) => ({ + queryKey, + queryFn: () => { + requestCount += 1 + return Promise.reject(new Error('skills refetch failed')) + }, + getNextPageParam: options.getNextPageParam, + initialPageParam: options.initialPageParam, + })) + const queryClient = createTestQueryClient() + queryClient.setQueryData(queryKey, { + pages: [{ data: [], has_more: false, page: 1, total: 0 }], + pageParams: [1], + }) + + renderSkillsPage(queryClient) + + const skillRegion = screen.getByRole('region', { + name: 'skill.skillManagement.listLabel', + }) + const error = await within(skillRegion).findByRole('alert') + expect(error).toHaveTextContent('skill.skillManagement.loadingError') + expect(within(skillRegion).queryByText('skill.skillManagement.empty')).not.toBeInTheDocument() + expect( + within(skillRegion).queryByRole('button', { + name: 'skill.skillManagement.emptyAction.createTitle', + }), + ).not.toBeInTheDocument() + expect(within(skillRegion).queryByRole('list')).not.toBeInTheDocument() + + await user.click(within(error).getByRole('button', { name: 'common.operation.retry' })) + await waitFor(() => { + expect(requestCount).toBe(2) + }) + }) + it('renders skills with tags, reference count, and detail links', async () => { renderSkillsPage() const skillLink = await screen.findByRole('link', { name: /Refund approval/ }) - expect(screen.getByRole('article', { name: 'Refund approval' })).toBeInTheDocument() + const skillList = screen.getByRole('list') + expect(screen.getByRole('listitem', { name: 'Refund approval' })).toBeInTheDocument() + expect(skillList).toContainElement(skillLink) expect(skillLink).toHaveAttribute('href', '/skills/skill-1') + expect(skillLink).toHaveAccessibleDescription('Handle refund requests.') + expect(within(skillLink).queryByRole('button')).not.toBeInTheDocument() expect(screen.getByText('refund-approval')).toBeInTheDocument() expect(screen.getByText('Handle refund requests.')).toBeInTheDocument() expect(screen.getByText('support')).toBeInTheDocument() @@ -350,8 +434,12 @@ describe('SkillsPage', () => { renderSkillsPage() + const skillLink = await screen.findByRole('link', { name: 'Refund approval' }) + expect(skillLink).toHaveAccessibleDescription( + 'skill.skillManagement.draft Handle refund requests.', + ) expect( - await screen.findByText('skill.skillManagement.editedAt:{"time":"2 hours ago"}'), + screen.getByText('skill.skillManagement.editedAt:{"time":"2 hours ago"}'), ).toBeInTheDocument() }) @@ -424,8 +512,28 @@ describe('SkillsPage', () => { name: 'skill-21', display_name: 'Skill 21', }) - mocks.skills = firstPageSkills - mocks.skillPages = [firstPageSkills, [nextPageSkill]] + let resolveNextPage: + | ((page: { data: SkillResponse[]; has_more: boolean; page: number; total: number }) => void) + | undefined + mocks.skillsQueryOptions.mockImplementation((options) => ({ + queryKey: ['skills-deferred-next-page', options], + queryFn: async ({ pageParam }: { pageParam: unknown }) => { + if (Number(pageParam) === 1) { + return { + data: firstPageSkills, + has_more: true, + page: 1, + total: 21, + } + } + + return new Promise((resolve) => { + resolveNextPage = resolve + }) + }, + getNextPageParam: options.getNextPageParam, + initialPageParam: options.initialPageParam, + })) renderSkillsPage() @@ -433,7 +541,7 @@ describe('SkillsPage', () => { name: 'skill.skillManagement.listLabel', }) await screen.findByRole('heading', { name: 'Skill 1' }) - expect(within(skillList).getAllByRole('article')).toHaveLength(20) + expect(within(skillList).getAllByRole('listitem')).toHaveLength(20) const scrollViewport = skillList.parentElement?.parentElement expect(scrollViewport).not.toBeNull() @@ -444,8 +552,19 @@ describe('SkillsPage', () => { }) fireEvent.scroll(scrollViewport!) + const paginationLoading = await within(skillList).findByRole('status') + expect(paginationLoading).toHaveTextContent('common.loading') + expect(paginationLoading).toBeVisible() + expect(within(skillList).getAllByRole('listitem')).toHaveLength(20) + + resolveNextPage?.({ + data: [nextPageSkill], + has_more: false, + page: 2, + total: 21, + }) expect(await screen.findByRole('heading', { name: 'Skill 21' })).toBeInTheDocument() - expect(within(skillList).getAllByRole('article')).toHaveLength(21) + expect(within(skillList).getAllByRole('listitem')).toHaveLength(21) expect(mocks.skillsQueryOptions.mock.lastCall?.[0].input(2)).toEqual({ query: { limit: 20, @@ -454,6 +573,165 @@ describe('SkillsPage', () => { }) }) + it('waits for a cached first page to refetch before auto-loading the next page', async () => { + const queryKey = ['skills-cached-refetch'] + const staleFirstPage = Array.from({ length: 20 }, (_, index) => + createSkill({ + id: `stale-skill-${index + 1}`, + name: `stale-skill-${index + 1}`, + display_name: `Stale Skill ${index + 1}`, + }), + ) + const freshFirstPage = staleFirstPage.map((skill, index) => + createSkill({ + ...skill, + display_name: `Fresh Skill ${index + 1}`, + }), + ) + let resolveFirstPageRefetch: + | ((page: { data: SkillResponse[]; has_more: boolean; page: number; total: number }) => void) + | undefined + let nextPageRequestCount = 0 + mocks.skillsQueryOptions.mockImplementation((options) => ({ + queryKey, + queryFn: ({ pageParam }: { pageParam: unknown }) => { + if (Number(pageParam) === 1) { + return new Promise((resolve) => { + resolveFirstPageRefetch = resolve + }) + } + + nextPageRequestCount += 1 + return Promise.resolve({ + data: [createSkill({ id: 'skill-21', name: 'skill-21', display_name: 'Fresh Skill 21' })], + has_more: false, + page: 2, + total: 21, + }) + }, + getNextPageParam: options.getNextPageParam, + initialPageParam: options.initialPageParam, + })) + const queryClient = createTestQueryClient() + queryClient.setQueryData(queryKey, { + pages: [ + { + data: staleFirstPage, + has_more: true, + page: 1, + total: 21, + }, + ], + pageParams: [1], + }) + + renderSkillsPage(queryClient) + + const skillRegion = screen.getByRole('region', { + name: 'skill.skillManagement.listLabel', + }) + expect(await screen.findByRole('heading', { name: 'Stale Skill 1' })).toBeInTheDocument() + const scrollViewport = skillRegion.parentElement?.parentElement + expect(scrollViewport).not.toBeNull() + Object.defineProperties(scrollViewport!, { + clientHeight: { configurable: true, value: 600 }, + scrollHeight: { configurable: true, value: 1200 }, + scrollTop: { configurable: true, value: 560 }, + }) + fireEvent.scroll(scrollViewport!) + + expect(nextPageRequestCount).toBe(0) + expect(screen.getByRole('heading', { name: 'Stale Skill 1' })).toBeInTheDocument() + + resolveFirstPageRefetch?.({ + data: freshFirstPage, + has_more: true, + page: 1, + total: 21, + }) + expect(await screen.findByRole('heading', { name: 'Fresh Skill 1' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Fresh Skill 21' })).toBeInTheDocument() + expect(nextPageRequestCount).toBe(1) + }) + + it('keeps loaded skills visible when the next page fails and exposes retry', async () => { + const user = userEvent.setup() + const firstPageSkills = Array.from({ length: 20 }, (_, index) => + createSkill({ + id: `skill-${index + 1}`, + name: `skill-${index + 1}`, + display_name: `Skill ${index + 1}`, + }), + ) + let nextPageRequestCount = 0 + let resolveRetry: + | ((page: { data: SkillResponse[]; has_more: boolean; page: number; total: number }) => void) + | undefined + mocks.skillsQueryOptions.mockImplementation((options) => ({ + queryKey: ['skills-next-page-error', options], + queryFn: async ({ pageParam }: { pageParam: unknown }) => { + if (Number(pageParam) === 1) { + return { + data: firstPageSkills, + has_more: true, + page: 1, + total: 21, + } + } + + nextPageRequestCount += 1 + if (nextPageRequestCount === 1) throw new Error('next skill page failed') + + return new Promise((resolve) => { + resolveRetry = resolve + }) + }, + getNextPageParam: options.getNextPageParam, + initialPageParam: options.initialPageParam, + })) + + renderSkillsPage() + + const skillRegion = await screen.findByRole('region', { + name: 'skill.skillManagement.listLabel', + }) + await screen.findByRole('heading', { name: 'Skill 1' }) + const scrollViewport = skillRegion.parentElement?.parentElement + expect(scrollViewport).not.toBeNull() + Object.defineProperties(scrollViewport!, { + clientHeight: { configurable: true, value: 600 }, + scrollHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, value: 0 }, + }) + fireEvent.scroll(scrollViewport!) + + const paginationError = await within(skillRegion).findByRole('alert') + expect(within(skillRegion).getAllByRole('listitem')).toHaveLength(20) + expect(paginationError).toHaveTextContent('skill.skillManagement.loadingError') + expect(nextPageRequestCount).toBe(1) + + const retryButton = within(paginationError).getByRole('button', { + name: 'common.operation.retry', + }) + await user.click(retryButton) + await waitFor(() => { + expect(nextPageRequestCount).toBe(2) + }) + expect(retryButton).toHaveAttribute('aria-disabled', 'true') + expect(retryButton).toHaveFocus() + expect(within(skillRegion).queryByRole('status')).not.toBeInTheDocument() + expect(within(skillRegion).getAllByRole('listitem')).toHaveLength(20) + + resolveRetry?.({ + data: [createSkill({ id: 'skill-21', name: 'skill-21', display_name: 'Recovered Skill 21' })], + has_more: false, + page: 2, + total: 21, + }) + expect(await screen.findByRole('heading', { name: 'Recovered Skill 21' })).toBeInTheDocument() + expect(within(skillRegion).getAllByRole('listitem')).toHaveLength(21) + }) + it('creates a placeholder skill and navigates to its detail page', async () => { const user = userEvent.setup() const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') @@ -825,7 +1103,13 @@ describe('SkillsPage', () => { renderSkillsPage() - expect(await screen.findByText('skill.skillManagement.emptySearch')).toBeInTheDocument() + const skillRegion = screen.getByRole('region', { + name: 'skill.skillManagement.listLabel', + }) + const emptySearchTitle = await within(skillRegion).findByText( + 'skill.skillManagement.emptySearch', + ) + expect(emptySearchTitle.closest('[role="status"]')).toBeInTheDocument() expect( screen.queryByText('skill.skillManagement.emptyAction.createTitle'), ).not.toBeInTheDocument() diff --git a/web/features/skills/detail/__tests__/file-tree-items.spec.tsx b/web/features/skills/detail/__tests__/file-tree-items.spec.tsx index 858d71618e3..30ec3ccc629 100644 --- a/web/features/skills/detail/__tests__/file-tree-items.spec.tsx +++ b/web/features/skills/detail/__tests__/file-tree-items.spec.tsx @@ -5,7 +5,7 @@ import type { import type { FileTreeNode } from '../shared' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' import { FileTreeItem, FileTreeNameInput } from '../file-tree-items' const skillFile: SkillFileResponse = { diff --git a/web/features/skills/detail/__tests__/markdown-editor.spec.tsx b/web/features/skills/detail/__tests__/markdown-editor.spec.tsx index c2173c53859..c8a58d4528b 100644 --- a/web/features/skills/detail/__tests__/markdown-editor.spec.tsx +++ b/web/features/skills/detail/__tests__/markdown-editor.spec.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createRef } from 'react' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' import { MarkdownBodyReferencePreview, MarkdownLiveBodyEditor } from '../markdown-editor' vi.mock('@/app/components/base/markdown', () => ({ diff --git a/web/features/skills/detail/__tests__/publish-bar.spec.tsx b/web/features/skills/detail/__tests__/publish-bar.spec.tsx index 4b5201a65e3..0be27952665 100644 --- a/web/features/skills/detail/__tests__/publish-bar.spec.tsx +++ b/web/features/skills/detail/__tests__/publish-bar.spec.tsx @@ -1,6 +1,6 @@ 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 { SkillPublishBar } from '../publish-bar' describe('SkillPublishBar', () => { diff --git a/web/features/skills/detail/__tests__/reference-chip.spec.ts b/web/features/skills/detail/__tests__/reference-chip.spec.ts index 972e610325c..4f658c59cfe 100644 --- a/web/features/skills/detail/__tests__/reference-chip.spec.ts +++ b/web/features/skills/detail/__tests__/reference-chip.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import { renderMarkdownLiveEditorContent, serializeMarkdownLiveEditorNode } from '../shared' describe('markdown reference chip', () => { diff --git a/web/features/skills/detail/__tests__/shared.spec.ts b/web/features/skills/detail/__tests__/shared.spec.ts index c60e48ba3b4..da353608bde 100644 --- a/web/features/skills/detail/__tests__/shared.spec.ts +++ b/web/features/skills/detail/__tests__/shared.spec.ts @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/react-query' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' import { consoleQuery } from '@/service/client' import { createUploadItemId, diff --git a/web/features/skills/detail/__tests__/shell.spec.tsx b/web/features/skills/detail/__tests__/shell.spec.tsx index 7059a04d14e..585aa57a194 100644 --- a/web/features/skills/detail/__tests__/shell.spec.tsx +++ b/web/features/skills/detail/__tests__/shell.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import { DetailSkeleton } from '../shell' describe('Skill detail shell', () => { diff --git a/web/features/skills/detail/__tests__/upload-workflow.spec.ts b/web/features/skills/detail/__tests__/upload-workflow.spec.ts index 04c3401be06..91ce0e597f7 100644 --- a/web/features/skills/detail/__tests__/upload-workflow.spec.ts +++ b/web/features/skills/detail/__tests__/upload-workflow.spec.ts @@ -1,5 +1,5 @@ import type { SkillFileResponse } from '@dify/contracts/api/console/workspaces/types.gen' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import { buildUploadReviewItems, createAvailableUploadPath, diff --git a/web/features/skills/page.tsx b/web/features/skills/page.tsx index 6c3ba9a71a9..3cc34a36e00 100644 --- a/web/features/skills/page.tsx +++ b/web/features/skills/page.tsx @@ -22,6 +22,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Field, FieldLabel } from '@langgenius/dify-ui/field' +import { IconButton } from '@langgenius/dify-ui/icon-button' import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' import { ScrollArea, @@ -38,6 +39,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { SkeletonRectangle } from '@/app/components/base/skeleton' +import { MAIN_NAV_APP_CARD_GRID_CLASS_NAME } from '@/app/components/main-nav/app-card-grid' import { SkillCardTags } from '@/features/tag-management/components/skill-card-tags' import { TagFilter } from '@/features/tag-management/components/tag-filter' import useDocumentTitle from '@/hooks/use-document-title' @@ -59,6 +61,7 @@ const placeholderCardIds = Array.from( ) const skeletonRows = ['primary', 'secondary', 'tertiary'] as const const SKILLS_PAGE_SIZE = 20 +const SKILL_GRID_CLASS_NAME = cn('gap-2.5', MAIN_NAV_APP_CARD_GRID_CLASS_NAME) function skillsListQueryKey(type: 'infinite' | 'query') { return consoleQuery.workspaces.current.skills.get.key({ type }) @@ -80,7 +83,7 @@ function SkillIcon() { ) } -function SkillCardSkeleton() { +function SkillCardSkeletonCards() { return ( <> {skeletonRows.map((row) => ( @@ -109,38 +112,60 @@ function SkillCardSkeleton() { ) } -function SkillPlaceholderState({ - canEdit, - creating, - importing, - isEmptySearch, - onCreate, - onImport, - title, -}: { - canEdit?: boolean - creating?: boolean - importing?: boolean - isEmptySearch?: boolean - onCreate?: () => void - onImport?: () => void +function SkillCardSkeleton() { + const { t } = useTranslation('common') + + return ( + <> + + {t(($) => $.loading)} + + + + ) +} + +type SkillPlaceholderActions = { + creating: boolean + importing: boolean + onCreate: () => void + onImport: () => void +} + +type SkillPlaceholderStateProps = { + role?: 'alert' | 'status' title: string -}) { +} & ( + | { actions?: SkillPlaceholderActions; isRetrying?: never; onRetry?: never } + | { actions?: never; isRetrying: boolean; onRetry: () => void } +) + +function SkillPlaceholderState({ + actions, + isRetrying, + onRetry, + role, + title, +}: SkillPlaceholderStateProps) { const { t } = useTranslation('skill') + const { t: tCommon } = useTranslation('common') return (
-
+
{placeholderCardIds.map((id) => (
))}
-
+
@@ -156,21 +181,26 @@ function SkillPlaceholderState({ > {title} + {onRetry && ( + + )}
- {!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/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,