From f2005143c9eb9dc21fee5330a2737cac6ef627a2 Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:14:20 +0000 Subject: [PATCH 1/6] chore(deps): adopt pnpm catalogs for shared dependency versions (#8937) Co-authored-by: Claude --- AGENTS.md | 21 + apps/storybook/package.json | 20 +- apps/web/package.json | 94 +- docker/package.json | 10 +- package.json | 20 +- packages/ai/package.json | 12 +- packages/cache/package.json | 14 +- packages/config-eslint/README.md | 2 +- packages/config-eslint/base.mjs | 13 + packages/config-eslint/package.json | 8 +- packages/config-eslint/react-hooks.mjs | 7 +- packages/config-eslint/react.mjs | 7 +- packages/config-prettier/package.json | 8 +- packages/config-typescript/package.json | 8 +- packages/database/package.json | 32 +- packages/email/package.json | 17 +- packages/i18n-utils/package.json | 14 +- packages/jobs/package.json | 12 +- packages/js-core/package.json | 12 +- packages/logger/package.json | 14 +- packages/storage/package.json | 8 +- packages/survey-ui/package.json | 68 +- .../survey-ui/scripts/assert-pnpm-publish.mjs | 54 + packages/surveys/package.json | 36 +- packages/types/package.json | 6 +- packages/vite-plugins/package.json | 8 +- packages/workflows/package.json | 18 +- pnpm-lock.yaml | 1143 ++++++++--------- pnpm-workspace.yaml | 101 ++ scripts/check-catalog.mjs | 191 +++ 30 files changed, 1153 insertions(+), 825 deletions(-) create mode 100755 packages/survey-ui/scripts/assert-pnpm-publish.mjs create mode 100755 scripts/check-catalog.mjs diff --git a/AGENTS.md b/AGENTS.md index 7ad2093e92a5..344df1f10581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,27 @@ to test; `email`, `types`, and `vite-plugins` are consumed from source, so they `apps/storybook` has no unit tests by policy (UI is covered by Playwright). Keep new packages on this matrix or document the exception here. +### Shared dependency versions (pnpm catalog) + +Every dependency used by **two or more** workspaces is pinned once in the `catalog:` block of +`pnpm-workspace.yaml`, and each `package.json` references it as `"catalog:"` instead of a version: + +```json +"devDependencies": { "typescript": "catalog:", "vitest": "catalog:" } +``` + +So bumping a shared dependency means editing the catalog entry — never a `package.json`. That is the +whole point: `nodeLinker: hoisted` hides a version split until it breaks, so `apps/web` was typing a +redis 5 client with redis 4's `RedisClientType` and one package was building on a different Vite major +than the other fourteen. Deps with a single consumer deliberately stay in their own `package.json`. + +`pnpm lint` runs `scripts/check-catalog.mjs`, which fails if a workspace declares a literal version for +a catalogued name, or if a dependency is declared by 2+ workspaces without being catalogued. A peer +dependency *range* is exempt — it is a compatibility declaration for consumers, not an install pin, so +it may legitimately be looser than the catalog (`packages/survey-ui` declares react `^19.0.0` while +pinning 19.2.6 to build against). Adding a new package needs no wiring: the check resolves the +workspace globs from `pnpm-workspace.yaml` itself. + ### Survey Packages Build & Cache The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the built bundle is copied to `apps/web/public/js/`. The Next.js app imports from `dist/`, **not** the source files. This means: diff --git a/apps/storybook/package.json b/apps/storybook/package.json index ac62c4f6cdfb..700cc9422483 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -13,22 +13,22 @@ }, "devDependencies": { "@chromatic-com/storybook": "5.0.2", - "@eslint/js": "9.39.5", + "@eslint/js": "catalog:", "@formbricks/config-typescript": "workspace:*", "@storybook/addon-a11y": "10.3.6", "@storybook/addon-docs": "10.3.6", "@storybook/addon-links": "10.3.6", "@storybook/addon-onboarding": "10.3.6", - "@storybook/react-vite": "10.3.6", - "@tailwindcss/vite": "4.2.4", - "@vitejs/plugin-react": "5.1.4", - "eslint-plugin-react-hooks": "7.1.1", + "@storybook/react-vite": "catalog:", + "@tailwindcss/vite": "catalog:", + "@vitejs/plugin-react": "catalog:", + "eslint-plugin-react-hooks": "catalog:", "eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-storybook": "10.3.6", - "globals": "16.5.0", - "storybook": "10.3.6", - "typescript": "5.9.3", - "typescript-eslint": "8.63.0", - "vite": "7.3.5" + "globals": "catalog:", + "storybook": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "vite": "catalog:" } } diff --git a/apps/web/package.json b/apps/web/package.json index 1556f2ef2399..d85b38cc8240 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,7 +47,7 @@ "@formbricks/surveys": "workspace:*", "@formbricks/types": "workspace:*", "@formbricks/workflows": "workspace:*", - "@formkit/auto-animate": "0.9.0", + "@formkit/auto-animate": "catalog:", "@hookform/resolvers": "5.2.2", "@json2csv/node": "7.0.6", "@lexical/code": "0.41.0", @@ -73,22 +73,22 @@ "@otplib/core": "12.0.1", "@otplib/plugin-crypto": "12.0.1", "@otplib/plugin-thirty-two": "12.0.1", - "@paralleldrive/cuid2": "2.3.1", + "@paralleldrive/cuid2": "catalog:", "@posthog/ai": "8.5.0", "@posthog/mcp": "0.11.6", - "@prisma/client": "7.8.0", + "@prisma/client": "catalog:", "@prisma/instrumentation": "7.8.0", - "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-checkbox": "catalog:", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-dropdown-menu": "catalog:", "@radix-ui/react-label": "2.1.8", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-popover": "catalog:", + "@radix-ui/react-radio-group": "catalog:", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-slot": "catalog:", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", @@ -97,36 +97,36 @@ "@stripe/stripe-js": "5.6.0", "@t3-oss/env-nextjs": "0.13.11", "@tailwindcss/forms": "0.5.11", - "@tailwindcss/postcss": "4.3.1", + "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "0.5.19", "@tanstack/react-query": "5.99.2", "@tanstack/react-table": "8.21.3", "@xyflow/react": "12.9.3", - "ai": "6.0.177", - "bcryptjs": "3.0.3", + "ai": "catalog:", + "bcryptjs": "catalog:", "better-auth": "1.6.23", "boring-avatars": "2.0.4", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", + "class-variance-authority": "catalog:", + "clsx": "catalog:", "cmdk": "1.1.1", "csv-parse": "6.1.0", - "date-fns": "4.1.0", + "date-fns": "catalog:", "disposable-email-domains": "1.0.62", "dompurify": "3.4.13", "framer-motion": "12.35.2", "googleapis": "171.4.0", "heic-convert": "2.1.0", - "i18next": "25.8.20", - "i18next-icu": "2.4.3", + "i18next": "catalog:", + "i18next-icu": "catalog:", "i18next-resources-to-backend": "1.2.1", "immer": "11.1.8", - "isomorphic-dompurify": "3.9.0", + "isomorphic-dompurify": "catalog:", "jiti": "2.6.1", "jose": "6.2.2", "jotai": "2.20.0", "jsonwebtoken": "9.0.3", "lexical": "0.41.0", - "lucide-react": "0.577.0", + "lucide-react": "catalog:", "markdown-it": "14.2.0", "mcp-handler": "2.1.1", "next": "16.2.11", @@ -139,67 +139,67 @@ "prismjs": "1.30.0", "qr-code-styling": "1.9.2", "qrcode": "1.5.4", - "react": "19.2.6", + "react": "catalog:", "react-calendar": "6.0.1", "react-colorful": "5.6.2", "react-confetti": "6.4.0", - "react-day-picker": "9.14.0", - "react-dom": "19.2.6", + "react-day-picker": "catalog:", + "react-dom": "catalog:", "react-grid-layout": "2.2.2", "react-hook-form": "7.71.2", "react-hot-toast": "2.6.0", - "react-i18next": "16.5.8", + "react-i18next": "catalog:", "react-turnstile": "1.1.5", "react-use": "17.6.1", "recharts": "2.15.3", - "redis": "4.7.1", + "redis": "catalog:", "sanitize-html": "2.17.5", "server-only": "0.0.1", "sharp": "0.35.0", "stripe": "20.4.1", - "tailwind-merge": "3.5.0", - "tailwindcss": "4.3.1", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", "ua-parser-js": "2.0.10", "undici": "7.29.0", - "uuid": "14.0.0", + "uuid": "catalog:", "xlsx": "file:vendor/xlsx-0.20.3.tgz", "yaml": "2.9.0", - "zod": "4.3.6", - "zod-openapi": "5.4.6" + "zod": "catalog:", + "zod-openapi": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@playwright/test": "1.58.2", - "@storybook/react": "10.3.6", - "@storybook/react-vite": "10.3.6", + "@playwright/test": "catalog:", + "@storybook/react": "catalog:", + "@storybook/react-vite": "catalog:", "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "16.3.2", + "@testing-library/react": "catalog:", "@types/heic-convert": "2.1.0", "@types/jsonwebtoken": "9.0.10", "@types/markdown-it": "14.1.2", - "@types/node": "25.4.0", + "@types/node": "catalog:", "@types/nodemailer": "7.0.11", "@types/papaparse": "5.5.2", "@types/qrcode": "1.5.6", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "@types/sanitize-html": "2.16.1", - "@vitejs/plugin-react": "5.1.4", - "@vitest/coverage-v8": "4.1.6", - "cross-env": "10.1.0", - "dotenv": "17.3.1", - "dotenv-cli": "11.0.0", + "@vitejs/plugin-react": "catalog:", + "@vitest/coverage-v8": "catalog:", + "cross-env": "catalog:", + "dotenv": "catalog:", + "dotenv-cli": "catalog:", "playwright": "1.58.2", - "jsdom": "29.1.1", - "postcss": "8.5.23", + "jsdom": "catalog:", + "postcss": "catalog:", "resize-observer-polyfill": "1.5.1", - "storybook": "10.3.6", + "storybook": "catalog:", "tailwindcss-animate": "1.0.7", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-tsconfig-paths": "6.1.1", - "vitest": "4.1.6", + "typescript": "catalog:", + "vite": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:", "vitest-mock-extended": "3.1.1" } } diff --git a/docker/package.json b/docker/package.json index 636aa39e0364..9bdc3ad4c1f0 100644 --- a/docker/package.json +++ b/docker/package.json @@ -6,10 +6,10 @@ "test:coverage": "vitest run --coverage" }, "devDependencies": { - "@types/js-yaml": "4.0.9", - "@vitest/coverage-v8": "4.1.6", - "js-yaml": "4.3.1", - "vite": "7.3.5", - "vitest": "4.1.6" + "@types/js-yaml": "catalog:", + "@vitest/coverage-v8": "catalog:", + "js-yaml": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/package.json b/package.json index f74443bbb4c1..7768e4f2975c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "generate": "turbo run generate", - "lint": "turbo run lint && pnpm api:v3:lint", + "catalog:check": "node scripts/check-catalog.mjs", + "lint": "turbo run lint && pnpm api:v3:lint && pnpm catalog:check", "typecheck": "turbo run typecheck", "test": "turbo run test --no-cache", "test:coverage": "turbo run test:coverage --no-cache", @@ -48,18 +49,19 @@ "@axe-core/playwright": "4.11.3", "@azure/playwright": "1.1.5", "@formbricks/config-eslint": "workspace:*", - "@playwright/test": "1.58.2", + "@playwright/test": "catalog:", "@redocly/cli": "1.34.17", - "@trivago/prettier-plugin-sort-imports": "6.0.2", - "dotenv": "17.3.1", + "@trivago/prettier-plugin-sort-imports": "catalog:", + "dotenv": "catalog:", "eslint": "9.39.5", "husky": "9.1.7", + "js-yaml": "catalog:", "lint-staged": "16.4.0", - "prettier": "3.8.3", - "prettier-plugin-sort-json": "4.2.0", - "prettier-plugin-tailwindcss": "0.7.4", - "rimraf": "6.1.3", - "tsx": "4.21.0", + "prettier": "catalog:", + "prettier-plugin-sort-json": "catalog:", + "prettier-plugin-tailwindcss": "catalog:", + "rimraf": "catalog:", + "tsx": "catalog:", "turbo": "2.9.14" }, "engines": { diff --git a/packages/ai/package.json b/packages/ai/package.json index 23e55da43623..45a8219d3f5e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -46,15 +46,15 @@ "@aws-sdk/credential-providers": "3.1017.0", "@formbricks/logger": "workspace:*", "@formbricks/types": "workspace:*", - "ai": "6.0.177" + "ai": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@vitest/coverage-v8": "4.1.6", - "rimraf": "6.1.3", - "typescript": "5.9.3", - "vite": "8.0.16", - "vitest": "4.1.6" + "@vitest/coverage-v8": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/cache/package.json b/packages/cache/package.json index 9df69ccf6491..e0360c4cf2e1 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -41,16 +41,16 @@ "author": "Formbricks ", "dependencies": { "@formbricks/logger": "workspace:*", - "redis": "5.11.0", - "zod": "4.3.6" + "redis": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@vitest/coverage-v8": "4.1.6", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-plugin-dts": "4.5.4", - "vitest": "4.1.6" + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/config-eslint/README.md b/packages/config-eslint/README.md index 12410429b7b3..19d215fd2ccf 100644 --- a/packages/config-eslint/README.md +++ b/packages/config-eslint/README.md @@ -25,5 +25,5 @@ Notes: - Type-aware linting requires every linted `.ts`/`.tsx` file to belong to the package's tsconfig (or the `projectService` default project). Plain `.js`/`.cjs`/`.mjs` files get the type-aware rules switched off (`disableTypeChecked`), and `*.config.*` / declaration files are ignored. -- `apps/storybook` keeps a self-contained flat config (it needs `eslint-plugin-react-hooks` v7's `configs.flat.*`; the tiers here pin v5, whose `recommended-latest` is the flat entry — unify when the v7 compiler-era rules are adopted, tracked under ENG-2366). +- `apps/storybook` keeps a self-contained flat config. The reason it originally had to (it needed `eslint-plugin-react-hooks` v7 while these tiers pinned v5) is gone — ENG-1689 moved the plugin into the pnpm catalog, so every tier is on v7 and uses `configs.flat.*`. Folding the storybook config into these tiers is now unblocked, tracked under ENG-2366. - Stale `eslint-disable` directives are reported as warnings workspace-wide (`reportUnusedDisableDirectives` in `base.mjs`). diff --git a/packages/config-eslint/base.mjs b/packages/config-eslint/base.mjs index 9ba124e1cfd9..fddf9b4fbb24 100644 --- a/packages/config-eslint/base.mjs +++ b/packages/config-eslint/base.mjs @@ -62,5 +62,18 @@ export const unusedDirectivesConvention = { }, }; +// TODO(ENG-2366): enable incrementally — pre-existing violations from the React Compiler-era +// react-hooks rules that v7's `flat.recommended` turns on. Only the rules with existing +// violations in the tiers' consumers are listed; the rest of the v7 rule set stays enforced. +// `apps/web/eslint.config.mjs` carries the same opt-out for the `next` tier. +export const reactCompilerRulesOptOut = { + rules: { + "react-hooks/purity": "off", + "react-hooks/immutability": "off", + "react-hooks/set-state-in-effect": "off", + "react-hooks/refs": "off", + }, +}; + // Turbo checks + test conventions + prettier-compat. Keep prettier last. export const base = [...turbo, vitestConventions, unusedDirectivesConvention, prettier]; diff --git a/packages/config-eslint/package.json b/packages/config-eslint/package.json index 1b70b1f4f26b..a1e89b842073 100644 --- a/packages/config-eslint/package.json +++ b/packages/config-eslint/package.json @@ -11,15 +11,15 @@ "./react-hooks": "./react-hooks.mjs" }, "dependencies": { - "@eslint/js": "9.39.5", + "@eslint/js": "catalog:", "@vitest/eslint-plugin": "1.6.17", "eslint-config-next": "16.2.10", "eslint-config-prettier": "10.1.8", "eslint-config-turbo": "2.8.21", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "5.2.0", - "globals": "16.5.0", - "typescript-eslint": "8.63.0" + "eslint-plugin-react-hooks": "catalog:", + "globals": "catalog:", + "typescript-eslint": "catalog:" } } diff --git a/packages/config-eslint/react-hooks.mjs b/packages/config-eslint/react-hooks.mjs index fcd7f8c1fa76..340443115c2a 100644 --- a/packages/config-eslint/react-hooks.mjs +++ b/packages/config-eslint/react-hooks.mjs @@ -1,6 +1,6 @@ import reactHooks from "eslint-plugin-react-hooks"; import globals from "globals"; -import { base, commonIgnores, typescriptParsing } from "./base.mjs"; +import { base, commonIgnores, reactCompilerRulesOptOut, typescriptParsing } from "./base.mjs"; /* * Flat config mirroring the old `legacy-react.js` tier: turbo + prettier + @@ -11,7 +11,9 @@ import { base, commonIgnores, typescriptParsing } from "./base.mjs"; export const reactHooksConfig = [ commonIgnores, typescriptParsing, - reactHooks.configs["recommended-latest"], + // v7 exposes flat configs under `configs.flat.*`; the top-level `recommended-latest` + // is the legacy eslintrc format and crashes ESLint 9. + reactHooks.configs.flat.recommended, { languageOptions: { globals: { @@ -19,6 +21,7 @@ export const reactHooksConfig = [ }, }, }, + reactCompilerRulesOptOut, ...base, ]; diff --git a/packages/config-eslint/react.mjs b/packages/config-eslint/react.mjs index 151c6f4c4495..1d96aad52097 100644 --- a/packages/config-eslint/react.mjs +++ b/packages/config-eslint/react.mjs @@ -4,7 +4,7 @@ import reactPlugin from "eslint-plugin-react"; import reactHooks from "eslint-plugin-react-hooks"; import globals from "globals"; import tseslint from "typescript-eslint"; -import { base, commonIgnores, unusedVarsConvention } from "./base.mjs"; +import { base, commonIgnores, reactCompilerRulesOptOut, unusedVarsConvention } from "./base.mjs"; /* * Flat config for React component libraries — the successor of the @@ -44,7 +44,9 @@ export const react = ({ tsconfigRootDir }) => [ // Accessibility rules (parity with the old @vercel/style-guide/react config; eslint-config-next // already provides these for the app, so this brings the react-library tier in line). jsxA11y.flatConfigs.recommended, - reactHooks.configs["recommended-latest"], + // v7 exposes flat configs under `configs.flat.*`; the top-level `recommended-latest` + // is the legacy eslintrc format and crashes ESLint 9. + reactHooks.configs.flat.recommended, { languageOptions: { globals: { @@ -57,6 +59,7 @@ export const react = ({ tsconfigRootDir }) => [ }, }, }, + reactCompilerRulesOptOut, ...base, ]; diff --git a/packages/config-prettier/package.json b/packages/config-prettier/package.json index 08004c9b0676..44a8f94d6d43 100644 --- a/packages/config-prettier/package.json +++ b/packages/config-prettier/package.json @@ -7,9 +7,9 @@ "clean": "rimraf node_modules .turbo" }, "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "6.0.2", - "prettier": "3.8.3", - "prettier-plugin-tailwindcss": "0.7.4", - "prettier-plugin-sort-json": "4.2.0" + "@trivago/prettier-plugin-sort-imports": "catalog:", + "prettier": "catalog:", + "prettier-plugin-tailwindcss": "catalog:", + "prettier-plugin-sort-json": "catalog:" } } diff --git a/packages/config-typescript/package.json b/packages/config-typescript/package.json index f5664b855af4..df0699b30f4c 100644 --- a/packages/config-typescript/package.json +++ b/packages/config-typescript/package.json @@ -7,9 +7,9 @@ "clean": "rimraf node_modules dist turbo" }, "devDependencies": { - "@types/node": "25.4.0", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "typescript": "5.9.3" + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/database/package.json b/packages/database/package.json index b5e529d95344..e289376fae72 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -72,30 +72,30 @@ "@formbricks/logger": "workspace:*", "@formbricks/types": "workspace:*", "@formbricks/workflows": "workspace:*", - "@paralleldrive/cuid2": "2.3.1", + "@paralleldrive/cuid2": "catalog:", "@prisma/adapter-pg": "7.8.0", - "@prisma/client": "7.8.0", - "bcryptjs": "3.0.3", + "@prisma/client": "catalog:", + "bcryptjs": "catalog:", "pg": "8.16.3", - "pino": "10.3.1", - "uuid": "14.0.0", - "zod": "4.3.6", - "zod-openapi": "5.4.6" + "pino": "catalog:", + "uuid": "catalog:", + "zod": "catalog:", + "zod-openapi": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", "@types/pg": "8.16.0", - "@vitest/coverage-v8": "4.1.6", - "dotenv": "17.3.1", - "dotenv-cli": "11.0.0", - "glob": "13.0.6", + "@vitest/coverage-v8": "catalog:", + "dotenv": "catalog:", + "dotenv-cli": "catalog:", + "glob": "catalog:", "prisma": "7.8.0", "prisma-json-types-generator": "4.1.1", - "tsx": "4.21.0", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-plugin-dts": "4.5.4", - "vitest": "4.1.6" + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/email/package.json b/packages/email/package.json index b904cbbec248..9b8323c7cd95 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -26,18 +26,19 @@ "@formbricks/types": "workspace:*", "@react-email/components": "1.0.12", "@react-email/render": "2.0.6", - "clsx": "2.1.1", - "lucide-react": "0.577.0", - "react": "19.2.6", + "clsx": "catalog:", + "lucide-react": "catalog:", + "react": "catalog:", "react-email": "5.2.11", - "tailwind-merge": "3.5.0" + "tailwind-merge": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@types/react": "19.2.14", - "@vitest/coverage-v8": "4.1.6", - "typescript": "5.9.3", - "vitest": "4.1.6" + "@types/react": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/i18n-utils/package.json b/packages/i18n-utils/package.json index 035b6d736769..087d6511aff3 100644 --- a/packages/i18n-utils/package.json +++ b/packages/i18n-utils/package.json @@ -45,18 +45,18 @@ "scan-translations": "tsx src/scan-translations.ts" }, "dependencies": { - "glob": "13.0.6", + "glob": "catalog:", "object-hash": "3.0.0" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@types/node": "25.4.0", + "@types/node": "catalog:", "@types/object-hash": "3.0.6", - "rimraf": "6.1.3", - "tsx": "4.21.0", - "typescript": "5.9.3", - "vite": "7.3.5", - "vitest": "4.1.6" + "rimraf": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 6b60245662ba..214daaf3f936 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -29,15 +29,15 @@ "@formbricks/types": "workspace:*", "bullmq": "5.61.0", "ioredis": "5.8.1", - "zod": "4.3.6" + "zod": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@vitest/coverage-v8": "4.1.6", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-plugin-dts": "4.5.4", - "vitest": "4.1.6" + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/js-core/package.json b/packages/js-core/package.json index fa31713da841..dc34c7e43609 100644 --- a/packages/js-core/package.json +++ b/packages/js-core/package.json @@ -43,11 +43,11 @@ "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", "@formbricks/vite-plugins": "workspace:*", - "@types/node": "25.4.0", - "@vitest/coverage-v8": "4.1.6", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-plugin-dts": "4.5.4", - "vitest": "4.1.6" + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/logger/package.json b/packages/logger/package.json index 17c6fe1b602b..c1a46e8b3eef 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -39,18 +39,18 @@ }, "author": "Formbricks ", "dependencies": { - "pino": "10.3.1", + "pino": "catalog:", "pino-opentelemetry-transport": "3.0.0", "pino-pretty": "13.1.3", - "zod": "4.3.6" + "zod": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@vitest/coverage-v8": "4.1.6", - "rimraf": "6.1.3", - "typescript": "5.9.3", - "vite": "7.3.5", - "vitest": "4.1.6" + "@vitest/coverage-v8": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/storage/package.json b/packages/storage/package.json index 7a6e02cf0086..5cb4773dd91f 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -47,9 +47,9 @@ "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@vitest/coverage-v8": "4.1.6", - "typescript": "5.9.3", - "vite": "7.3.5", - "vitest": "4.1.6" + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/survey-ui/package.json b/packages/survey-ui/package.json index b6e7b1073572..a07c075c4a19 100644 --- a/packages/survey-ui/package.json +++ b/packages/survey-ui/package.json @@ -59,6 +59,8 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "preview": "vite preview", + "prepack": "node scripts/assert-pnpm-publish.mjs", + "prepublishOnly": "node scripts/assert-pnpm-publish.mjs", "clean": "rimraf .turbo node_modules dist coverage", "ui:add": "npx shadcn@latest add", "test": "vitest run", @@ -69,43 +71,43 @@ "react-dom": "^19.0.0" }, "dependencies": { - "@formkit/auto-animate": "0.9.0", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-popover": "1.1.15", + "@formkit/auto-animate": "catalog:", + "@radix-ui/react-checkbox": "catalog:", + "@radix-ui/react-dropdown-menu": "catalog:", + "@radix-ui/react-popover": "catalog:", "@radix-ui/react-progress": "1.1.8", - "@radix-ui/react-radio-group": "1.3.8", - "@radix-ui/react-slot": "1.2.4", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "date-fns": "4.1.0", - "isomorphic-dompurify": "3.9.0", - "lucide-react": "0.577.0", - "react-day-picker": "9.14.0", - "tailwind-merge": "3.5.0" + "@radix-ui/react-radio-group": "catalog:", + "@radix-ui/react-slot": "catalog:", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "date-fns": "catalog:", + "isomorphic-dompurify": "catalog:", + "lucide-react": "catalog:", + "react-day-picker": "catalog:", + "tailwind-merge": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@storybook/react": "10.3.6", - "@storybook/react-vite": "10.3.6", - "@tailwindcss/postcss": "4.2.4", - "@tailwindcss/vite": "4.2.4", - "@testing-library/react": "16.3.2", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "5.1.4", - "@vitest/coverage-v8": "4.1.6", - "preact": "10.29.1", - "jsdom": "29.1.1", - "react": "19.2.6", - "react-dom": "19.2.6", - "rimraf": "6.1.3", - "tailwindcss": "4.2.4", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-plugin-dts": "4.5.4", - "vite-tsconfig-paths": "6.1.1", - "vitest": "4.1.6" + "@storybook/react": "catalog:", + "@storybook/react-vite": "catalog:", + "@tailwindcss/postcss": "catalog:", + "@tailwindcss/vite": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitest/coverage-v8": "catalog:", + "preact": "catalog:", + "jsdom": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "rimraf": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/survey-ui/scripts/assert-pnpm-publish.mjs b/packages/survey-ui/scripts/assert-pnpm-publish.mjs new file mode 100755 index 000000000000..f54f11ceb5b8 --- /dev/null +++ b/packages/survey-ui/scripts/assert-pnpm-publish.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Publish guard: this package must be released with `pnpm publish`, never `npm publish`. + * + * @formbricks/survey-ui is the one workspace package that is actually published (see its + * README). Its runtime `dependencies` are declared as `"catalog:"`, resolved from the + * `catalog:` block in pnpm-workspace.yaml (ENG-1689). pnpm rewrites those to concrete + * versions when it packs the tarball; npm does not — it would ship `"clsx": "catalog:"` + * verbatim, which no consumer can install. The same applies to `workspace:` specifiers. + * + * That failure is invisible at publish time and only surfaces for whoever installs the + * broken version, so this fails the publish instead. Every release so far was cut by hand + * (all of 1.0.0–1.0.4 went out in one 66-minute session, with no release workflow), which + * is exactly the situation where the wrong client gets used. + * + * Wired to both `prepublishOnly` and `prepack`, because they cover different escapes: + * `prepublishOnly` catches `npm publish .`, and `prepack` catches `npm pack` — whose tarball + * would otherwise carry the raw specifiers and be publishable later via + * `npm publish `, which runs no scripts of its own. Neither hook fires under + * `--ignore-scripts`; nothing in package.json can close that, so it stays a known hole. + * `pnpm pack`, `pnpm publish`, `pnpm build` and installs are unaffected — pnpm is allowed, + * and neither hook runs on install. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/* eslint-disable-next-line turbo/no-undeclared-env-vars -- injected by the package manager at + publish time, not build config: it is never a turbo task input, so declaring it in turbo.json + would only make every task hash against the client's UA string. */ +const userAgent = process.env.npm_config_user_agent ?? ""; +const isPnpm = userAgent.startsWith("pnpm/"); + +if (isPnpm) process.exit(0); + +// Name the specifiers that would actually break, so the message cannot go stale. +const manifest = JSON.parse(readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8")); +const unresolvable = Object.entries({ ...manifest.dependencies, ...manifest.peerDependencies }) + .filter(([, spec]) => spec.startsWith("catalog:") || spec.startsWith("workspace:")) + .map(([name, spec]) => ` ${name}: "${spec}"`); + +console.error(` +✗ Refusing to publish @formbricks/survey-ui with this client. + + Detected: ${userAgent || "no npm_config_user_agent (unrecognised client)"} + Required: pnpm + + Only pnpm resolves the catalog:/workspace: specifiers in this package's manifest. + Publishing with npm would ship these to consumers verbatim, and the install would fail: + +${unresolvable.length > 0 ? unresolvable.join("\n") : " (none right now — but keep the guard: the deps are catalogued)"} + + Run: pnpm publish --filter @formbricks/survey-ui +`); +process.exit(1); diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 32d64ce28c32..a464d8c687a7 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -47,12 +47,12 @@ "dependencies": { "@calcom/embed-snippet": "1.3.3", "@formbricks/survey-ui": "workspace:*", - "i18next": "25.8.20", - "i18next-icu": "2.4.3", - "isomorphic-dompurify": "3.9.0", - "preact": "10.29.1", - "react-i18next": "16.5.8", - "tailwind-merge": "3.5.0" + "i18next": "catalog:", + "i18next-icu": "catalog:", + "isomorphic-dompurify": "catalog:", + "preact": "catalog:", + "react-i18next": "catalog:", + "tailwind-merge": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", @@ -60,23 +60,23 @@ "@formbricks/i18n-utils": "workspace:*", "@formbricks/types": "workspace:*", "@preact/preset-vite": "2.10.5", - "@tailwindcss/postcss": "4.2.4", + "@tailwindcss/postcss": "catalog:", "@testing-library/preact": "3.2.4", - "@types/node": "25.4.0", - "@types/react": "19.2.14", + "@types/node": "catalog:", + "@types/react": "catalog:", "autoprefixer": "10.4.27", "concurrently": "9.2.4", - "cross-env": "10.1.0", - "dotenv-cli": "11.0.0", + "cross-env": "catalog:", + "dotenv-cli": "catalog:", "fake-indexeddb": "6.2.5", "happy-dom": "20.8.9", - "postcss": "8.5.23", + "postcss": "catalog:", "rollup-plugin-visualizer": "7.0.1", - "tailwindcss": "4.2.4", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-tsconfig-paths": "6.1.1", - "vitest": "4.1.6", - "zod": "4.3.6" + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" } } diff --git a/packages/types/package.json b/packages/types/package.json index d9d1eaf12d64..97a5e9b5994b 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -13,12 +13,12 @@ "clean": "rimraf node_modules .turbo" }, "dependencies": { - "zod": "4.3.6", - "zod-openapi": "5.4.6", + "zod": "catalog:", + "zod-openapi": "catalog:", "node-html-parser": "7.1.0" }, "devDependencies": { "@formbricks/config-typescript": "workspace:*", - "typescript": "5.9.3" + "typescript": "catalog:" } } diff --git a/packages/vite-plugins/package.json b/packages/vite-plugins/package.json index e2372917642a..418ca69af732 100644 --- a/packages/vite-plugins/package.json +++ b/packages/vite-plugins/package.json @@ -20,9 +20,9 @@ "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "postcss": "8.5.23", - "typescript": "5.9.3", - "vite": "7.3.5", - "vitest": "4.1.6" + "postcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 53b4c3228ee3..4d9c21430fd0 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -25,19 +25,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "zod": "4.3.6" + "zod": "catalog:" }, "devDependencies": { "@formbricks/config-eslint": "workspace:*", "@formbricks/config-typescript": "workspace:*", - "@types/js-yaml": "4.0.9", - "@vitest/coverage-v8": "4.1.6", - "js-yaml": "4.3.1", + "@types/js-yaml": "catalog:", + "@vitest/coverage-v8": "catalog:", + "js-yaml": "catalog:", "publint": "0.3.21", - "rimraf": "6.1.3", - "typescript": "5.9.3", - "vite": "7.3.5", - "vite-plugin-dts": "4.5.4", - "vitest": "4.1.6" + "rimraf": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9edecf16268..32361161bab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,198 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@eslint/js': + specifier: 9.39.5 + version: 9.39.5 + '@formkit/auto-animate': + specifier: 0.9.0 + version: 0.9.0 + '@paralleldrive/cuid2': + specifier: 2.3.1 + version: 2.3.1 + '@playwright/test': + specifier: 1.58.2 + version: 1.58.2 + '@prisma/client': + specifier: 7.8.0 + version: 7.8.0 + '@radix-ui/react-checkbox': + specifier: 1.3.3 + version: 1.3.3 + '@radix-ui/react-dropdown-menu': + specifier: 2.1.16 + version: 2.1.16 + '@radix-ui/react-popover': + specifier: 1.1.15 + version: 1.1.15 + '@radix-ui/react-radio-group': + specifier: 1.3.8 + version: 1.3.8 + '@radix-ui/react-slot': + specifier: 1.2.4 + version: 1.2.4 + '@storybook/react': + specifier: 10.3.6 + version: 10.3.6 + '@storybook/react-vite': + specifier: 10.3.6 + version: 10.3.6 + '@tailwindcss/postcss': + specifier: 4.3.1 + version: 4.3.1 + '@tailwindcss/vite': + specifier: 4.3.1 + version: 4.3.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2 + '@trivago/prettier-plugin-sort-imports': + specifier: 6.0.2 + version: 6.0.2 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 + '@types/node': + specifier: 25.4.0 + version: 25.4.0 + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3 + '@vitejs/plugin-react': + specifier: 5.1.4 + version: 5.1.4 + '@vitest/coverage-v8': + specifier: 4.1.6 + version: 4.1.6 + ai: + specifier: 6.0.177 + version: 6.0.177 + bcryptjs: + specifier: 3.0.3 + version: 3.0.3 + class-variance-authority: + specifier: 0.7.1 + version: 0.7.1 + clsx: + specifier: 2.1.1 + version: 2.1.1 + cross-env: + specifier: 10.1.0 + version: 10.1.0 + date-fns: + specifier: 4.1.0 + version: 4.1.0 + dotenv: + specifier: 17.3.1 + version: 17.3.1 + dotenv-cli: + specifier: 11.0.0 + version: 11.0.0 + eslint-plugin-react-hooks: + specifier: 7.1.1 + version: 7.1.1 + glob: + specifier: 13.0.6 + version: 13.0.6 + globals: + specifier: 16.5.0 + version: 16.5.0 + i18next: + specifier: 25.8.20 + version: 25.8.20 + i18next-icu: + specifier: 2.4.3 + version: 2.4.3 + isomorphic-dompurify: + specifier: 3.9.0 + version: 3.9.0 + js-yaml: + specifier: 4.3.1 + version: 4.3.1 + jsdom: + specifier: 29.1.1 + version: 29.1.1 + lucide-react: + specifier: 0.577.0 + version: 0.577.0 + pino: + specifier: 10.3.1 + version: 10.3.1 + preact: + specifier: 10.29.1 + version: 10.29.1 + prettier: + specifier: 3.8.3 + version: 3.8.3 + prettier-plugin-sort-json: + specifier: 4.2.0 + version: 4.2.0 + prettier-plugin-tailwindcss: + specifier: 0.7.4 + version: 0.7.4 + react: + specifier: 19.2.6 + version: 19.2.6 + react-day-picker: + specifier: 9.14.0 + version: 9.14.0 + react-dom: + specifier: 19.2.6 + version: 19.2.6 + react-i18next: + specifier: 16.5.8 + version: 16.5.8 + redis: + specifier: 5.11.0 + version: 5.11.0 + rimraf: + specifier: 6.1.3 + version: 6.1.3 + storybook: + specifier: 10.3.6 + version: 10.3.6 + tailwind-merge: + specifier: 3.5.0 + version: 3.5.0 + tailwindcss: + specifier: 4.3.1 + version: 4.3.1 + tsx: + specifier: 4.21.0 + version: 4.21.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: 8.63.0 + version: 8.63.0 + uuid: + specifier: 14.0.0 + version: 14.0.0 + vite: + specifier: 7.3.5 + version: 7.3.5 + vite-plugin-dts: + specifier: 4.5.4 + version: 4.5.4 + vite-tsconfig-paths: + specifier: 6.1.1 + version: 6.1.1 + vitest: + specifier: 4.1.6 + version: 4.1.6 + zod: + specifier: 4.3.6 + version: 4.3.6 + zod-openapi: + specifier: 5.4.6 + version: 5.4.6 + overrides: '@grpc/grpc-js': 1.14.4 '@hono/node-server': 2.0.10 @@ -51,16 +243,16 @@ importers: specifier: workspace:* version: link:packages/config-eslint '@playwright/test': - specifier: 1.58.2 + specifier: 'catalog:' version: 1.58.2 '@redocly/cli': specifier: 1.34.17 version: 1.34.17(ajv@8.20.0)(encoding@0.1.13) '@trivago/prettier-plugin-sort-imports': - specifier: 6.0.2 + specifier: 'catalog:' version: 6.0.2(prettier@3.8.3) dotenv: - specifier: 17.3.1 + specifier: 'catalog:' version: 17.3.1 eslint: specifier: 9.39.5 @@ -68,23 +260,26 @@ importers: husky: specifier: 9.1.7 version: 9.1.7 + js-yaml: + specifier: 'catalog:' + version: 4.3.1 lint-staged: specifier: 16.4.0 version: 16.4.0 prettier: - specifier: 3.8.3 + specifier: 'catalog:' version: 3.8.3 prettier-plugin-sort-json: - specifier: 4.2.0 + specifier: 'catalog:' version: 4.2.0(prettier@3.8.3) prettier-plugin-tailwindcss: - specifier: 0.7.4 + specifier: 'catalog:' version: 0.7.4(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3) rimraf: - specifier: 6.1.3 + specifier: 'catalog:' version: 6.1.3 tsx: - specifier: 4.21.0 + specifier: 'catalog:' version: 4.21.0 turbo: specifier: 2.9.14 @@ -96,7 +291,7 @@ importers: specifier: 5.0.2 version: 5.0.2(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@eslint/js': - specifier: 9.39.5 + specifier: 'catalog:' version: 9.39.5 '@formbricks/config-typescript': specifier: workspace:* @@ -106,7 +301,7 @@ importers: version: 10.3.6(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@storybook/addon-docs': specifier: 10.3.6 - version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) + version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) '@storybook/addon-links': specifier: 10.3.6 version: 10.3.6(react@19.2.6)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) @@ -114,16 +309,16 @@ importers: specifier: 10.3.6 version: 10.3.6(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@storybook/react-vite': - specifier: 10.3.6 - version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) + specifier: 'catalog:' + version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) '@tailwindcss/vite': - specifier: 4.2.4 - version: 4.2.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.3.1(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitejs/plugin-react': - specifier: 5.1.4 - version: 5.1.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 5.1.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) eslint-plugin-react-hooks: - specifier: 7.1.1 + specifier: 'catalog:' version: 7.1.1(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: 0.4.26 @@ -132,20 +327,20 @@ importers: specifier: 10.3.6 version: 10.3.6(eslint@9.39.5(jiti@2.7.0))(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) globals: - specifier: 16.5.0 + specifier: 'catalog:' version: 16.5.0 storybook: - specifier: 10.3.6 + specifier: 'catalog:' version: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 typescript-eslint: - specifier: 8.63.0 + specifier: 'catalog:' version: 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) apps/web: dependencies: @@ -219,7 +414,7 @@ importers: specifier: workspace:* version: link:../../packages/workflows '@formkit/auto-animate': - specifier: 0.9.0 + specifier: 'catalog:' version: 0.9.0 '@hookform/resolvers': specifier: 5.2.2 @@ -297,7 +492,7 @@ importers: specifier: 12.0.1 version: 12.0.1 '@paralleldrive/cuid2': - specifier: 2.3.1 + specifier: 'catalog:' version: 2.3.1 '@posthog/ai': specifier: 8.5.0 @@ -306,13 +501,13 @@ importers: specifier: 0.11.6 version: 0.11.6(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(posthog-node@5.28.11(rxjs@7.8.2)) '@prisma/client': - specifier: 7.8.0 + specifier: 'catalog:' version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) '@prisma/instrumentation': specifier: 7.8.0 version: 7.8.0(@opentelemetry/api@1.9.0) '@radix-ui/react-checkbox': - specifier: 1.3.3 + specifier: 'catalog:' version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-collapsible': specifier: 1.1.12 @@ -321,16 +516,16 @@ importers: specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-dropdown-menu': - specifier: 2.1.16 + specifier: 'catalog:' version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-label': specifier: 2.1.8 version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-popover': - specifier: 1.1.15 + specifier: 'catalog:' version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-radio-group': - specifier: 1.3.8 + specifier: 'catalog:' version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-select': specifier: 2.2.6 @@ -342,7 +537,7 @@ importers: specifier: 1.3.6 version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-slot': - specifier: 1.2.4 + specifier: 'catalog:' version: 1.2.4(@types/react@19.2.14)(react@19.2.6) '@radix-ui/react-switch': specifier: 1.2.6 @@ -369,7 +564,7 @@ importers: specifier: 0.5.11 version: 0.5.11(tailwindcss@4.3.1) '@tailwindcss/postcss': - specifier: 4.3.1 + specifier: 'catalog:' version: 4.3.1 '@tailwindcss/typography': specifier: 0.5.19 @@ -384,10 +579,10 @@ importers: specifier: 12.9.3 version: 12.9.3(@types/react@19.2.14)(immer@11.1.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) ai: - specifier: 6.0.177 + specifier: 'catalog:' version: 6.0.177(zod@4.3.6) bcryptjs: - specifier: 3.0.3 + specifier: 'catalog:' version: 3.0.3 better-auth: specifier: 1.6.23 @@ -396,10 +591,10 @@ importers: specifier: 2.0.4 version: 2.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: - specifier: 0.7.1 + specifier: 'catalog:' version: 0.7.1 clsx: - specifier: 2.1.1 + specifier: 'catalog:' version: 2.1.1 cmdk: specifier: 1.1.1 @@ -408,7 +603,7 @@ importers: specifier: 6.1.0 version: 6.1.0 date-fns: - specifier: 4.1.0 + specifier: 'catalog:' version: 4.1.0 disposable-email-domains: specifier: 1.0.62 @@ -426,10 +621,10 @@ importers: specifier: 2.1.0 version: 2.1.0 i18next: - specifier: 25.8.20 + specifier: 'catalog:' version: 25.8.20(typescript@5.9.3) i18next-icu: - specifier: 2.4.3 + specifier: 'catalog:' version: 2.4.3(intl-messageformat@10.7.18) i18next-resources-to-backend: specifier: 1.2.1 @@ -438,7 +633,7 @@ importers: specifier: 11.1.8 version: 11.1.8 isomorphic-dompurify: - specifier: 3.9.0 + specifier: 'catalog:' version: 3.9.0(@noble/hashes@2.0.1) jiti: specifier: 2.6.1 @@ -456,7 +651,7 @@ importers: specifier: 0.41.0 version: 0.41.0 lucide-react: - specifier: 0.577.0 + specifier: 'catalog:' version: 0.577.0(react@19.2.6) markdown-it: specifier: 14.2.0 @@ -495,7 +690,7 @@ importers: specifier: 1.5.4 version: 1.5.4 react: - specifier: 19.2.6 + specifier: 'catalog:' version: 19.2.6 react-calendar: specifier: 6.0.1 @@ -507,10 +702,10 @@ importers: specifier: 6.4.0 version: 6.4.0(react@19.2.6) react-day-picker: - specifier: 9.14.0 + specifier: 'catalog:' version: 9.14.0(react@19.2.6) react-dom: - specifier: 19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) react-grid-layout: specifier: 2.2.2 @@ -522,7 +717,7 @@ importers: specifier: 2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-i18next: - specifier: 16.5.8 + specifier: 'catalog:' version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) react-turnstile: specifier: 1.1.5 @@ -534,8 +729,8 @@ importers: specifier: 2.15.3 version: 2.15.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) redis: - specifier: 4.7.1 - version: 4.7.1 + specifier: 'catalog:' + version: 5.11.0 sanitize-html: specifier: 2.17.5 version: 2.17.5 @@ -549,10 +744,10 @@ importers: specifier: 20.4.1 version: 20.4.1(@types/node@25.4.0) tailwind-merge: - specifier: 3.5.0 + specifier: 'catalog:' version: 3.5.0 tailwindcss: - specifier: 4.3.1 + specifier: 'catalog:' version: 4.3.1 ua-parser-js: specifier: 2.0.10 @@ -561,7 +756,7 @@ importers: specifier: 7.29.0 version: 7.29.0 uuid: - specifier: 14.0.0 + specifier: 'catalog:' version: 14.0.0 xlsx: specifier: file:vendor/xlsx-0.20.3.tgz @@ -570,10 +765,10 @@ importers: specifier: 2.9.0 version: 2.9.0 zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 zod-openapi: - specifier: 5.4.6 + specifier: 'catalog:' version: 5.4.6(zod@4.3.6) devDependencies: '@formbricks/config-eslint': @@ -583,19 +778,19 @@ importers: specifier: workspace:* version: link:../../packages/config-typescript '@playwright/test': - specifier: 1.58.2 + specifier: 'catalog:' version: 1.58.2 '@storybook/react': - specifier: 10.3.6 + specifier: 'catalog:' version: 10.3.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) '@storybook/react-vite': - specifier: 10.3.6 + specifier: 'catalog:' version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.23)) '@testing-library/jest-dom': specifier: 6.9.1 version: 6.9.1 '@testing-library/react': - specifier: 16.3.2 + specifier: 'catalog:' version: 16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/heic-convert': specifier: 2.1.0 @@ -607,7 +802,7 @@ importers: specifier: 14.1.2 version: 14.1.2 '@types/node': - specifier: 25.4.0 + specifier: 'catalog:' version: 25.4.0 '@types/nodemailer': specifier: 7.0.11 @@ -619,31 +814,31 @@ importers: specifier: 1.5.6 version: 1.5.6 '@types/react': - specifier: 19.2.14 + specifier: 'catalog:' version: 19.2.14 '@types/react-dom': - specifier: 19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) '@types/sanitize-html': specifier: 2.16.1 version: 2.16.1 '@vitejs/plugin-react': - specifier: 5.1.4 + specifier: 'catalog:' version: 5.1.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) cross-env: - specifier: 10.1.0 + specifier: 'catalog:' version: 10.1.0 dotenv: - specifier: 17.3.1 + specifier: 'catalog:' version: 17.3.1 dotenv-cli: - specifier: 11.0.0 + specifier: 'catalog:' version: 11.0.0 jsdom: - specifier: 29.1.1 + specifier: 'catalog:' version: 29.1.1(@noble/hashes@2.0.1) playwright: specifier: 1.58.2 @@ -655,22 +850,22 @@ importers: specifier: 1.5.1 version: 1.5.1 storybook: - specifier: 10.3.6 + specifier: 'catalog:' version: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwindcss-animate: specifier: 1.0.7 version: 1.0.7(tailwindcss@4.3.1) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 + specifier: 'catalog:' version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-tsconfig-paths: - specifier: 6.1.1 + specifier: 'catalog:' version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest-mock-extended: specifier: 3.1.1 @@ -679,20 +874,20 @@ importers: docker: devDependencies: '@types/js-yaml': - specifier: 4.0.9 + specifier: 'catalog:' version: 4.0.9 '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) js-yaml: - specifier: 4.3.1 + specifier: 'catalog:' version: 4.3.1 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/ai: dependencies: @@ -718,7 +913,7 @@ importers: specifier: workspace:* version: link:../types ai: - specifier: 6.0.177 + specifier: 'catalog:' version: 6.0.177(zod@4.3.6) devDependencies: '@formbricks/config-eslint': @@ -728,20 +923,20 @@ importers: specifier: workspace:* version: link:../config-typescript '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) rimraf: - specifier: 6.1.3 + specifier: 'catalog:' version: 6.1.3 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 8.0.16 - version: 8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/cache: dependencies: @@ -749,10 +944,10 @@ importers: specifier: workspace:* version: link:../logger redis: - specifier: 5.11.0 + specifier: 'catalog:' version: 5.11.0 zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 devDependencies: '@formbricks/config-eslint': @@ -762,25 +957,25 @@ importers: specifier: workspace:* version: link:../config-typescript '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-plugin-dts: - specifier: 4.5.4 - version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/config-eslint: dependencies: '@eslint/js': - specifier: 9.39.5 + specifier: 'catalog:' version: 9.39.5 '@vitest/eslint-plugin': specifier: 1.6.17 @@ -801,43 +996,43 @@ importers: specifier: 7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: - specifier: 5.2.0 - version: 5.2.0(eslint@9.39.5(jiti@2.7.0)) + specifier: 'catalog:' + version: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: - specifier: 16.5.0 + specifier: 'catalog:' version: 16.5.0 typescript-eslint: - specifier: 8.63.0 + specifier: 'catalog:' version: 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) packages/config-prettier: devDependencies: '@trivago/prettier-plugin-sort-imports': - specifier: 6.0.2 + specifier: 'catalog:' version: 6.0.2(prettier@3.8.3) prettier: - specifier: 3.8.3 + specifier: 'catalog:' version: 3.8.3 prettier-plugin-sort-json: - specifier: 4.2.0 + specifier: 'catalog:' version: 4.2.0(prettier@3.8.3) prettier-plugin-tailwindcss: - specifier: 0.7.4 + specifier: 'catalog:' version: 0.7.4(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3) packages/config-typescript: devDependencies: '@types/node': - specifier: 25.4.0 + specifier: 'catalog:' version: 25.4.0 '@types/react': - specifier: 19.2.14 + specifier: 'catalog:' version: 19.2.14 '@types/react-dom': - specifier: 19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 packages/database: @@ -855,31 +1050,31 @@ importers: specifier: workspace:* version: link:../workflows '@paralleldrive/cuid2': - specifier: 2.3.1 + specifier: 'catalog:' version: 2.3.1 '@prisma/adapter-pg': specifier: 7.8.0 version: 7.8.0 '@prisma/client': - specifier: 7.8.0 + specifier: 'catalog:' version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) bcryptjs: - specifier: 3.0.3 + specifier: 'catalog:' version: 3.0.3 pg: specifier: 8.16.3 version: 8.16.3 pino: - specifier: 10.3.1 + specifier: 'catalog:' version: 10.3.1 uuid: - specifier: 14.0.0 + specifier: 'catalog:' version: 14.0.0 zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 zod-openapi: - specifier: 5.4.6 + specifier: 'catalog:' version: 5.4.6(zod@4.3.6) devDependencies: '@formbricks/config-eslint': @@ -892,16 +1087,16 @@ importers: specifier: 8.16.0 version: 8.16.0 '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) dotenv: - specifier: 17.3.1 + specifier: 'catalog:' version: 17.3.1 dotenv-cli: - specifier: 11.0.0 + specifier: 'catalog:' version: 11.0.0 glob: - specifier: 13.0.6 + specifier: 'catalog:' version: 13.0.6 prisma: specifier: 7.8.0 @@ -910,20 +1105,20 @@ importers: specifier: 4.1.1 version: 4.1.1(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) tsx: - specifier: 4.21.0 + specifier: 'catalog:' version: 4.21.0 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-plugin-dts: - specifier: 4.5.4 - version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/email: dependencies: @@ -937,19 +1132,19 @@ importers: specifier: 2.0.6 version: 2.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) clsx: - specifier: 2.1.1 + specifier: 'catalog:' version: 2.1.1 lucide-react: - specifier: 0.577.0 + specifier: 'catalog:' version: 0.577.0(react@19.2.6) react: - specifier: 19.2.6 + specifier: 'catalog:' version: 19.2.6 react-email: specifier: 5.2.11 version: 5.2.11 tailwind-merge: - specifier: 3.5.0 + specifier: 'catalog:' version: 3.5.0 devDependencies: '@formbricks/config-eslint': @@ -959,22 +1154,25 @@ importers: specifier: workspace:* version: link:../config-typescript '@types/react': - specifier: 19.2.14 + specifier: 'catalog:' version: 19.2.14 '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 + vite: + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/i18n-utils: dependencies: glob: - specifier: 13.0.6 + specifier: 'catalog:' version: 13.0.6 object-hash: specifier: 3.0.0 @@ -987,26 +1185,26 @@ importers: specifier: workspace:* version: link:../config-typescript '@types/node': - specifier: 25.4.0 + specifier: 'catalog:' version: 25.4.0 '@types/object-hash': specifier: 3.0.6 version: 3.0.6 rimraf: - specifier: 6.1.3 + specifier: 'catalog:' version: 6.1.3 tsx: - specifier: 4.21.0 + specifier: 'catalog:' version: 4.21.0 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/jobs: dependencies: @@ -1026,7 +1224,7 @@ importers: specifier: 5.8.1 version: 5.8.1 zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 devDependencies: '@formbricks/config-eslint': @@ -1036,20 +1234,20 @@ importers: specifier: workspace:* version: link:../config-typescript '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-plugin-dts: - specifier: 4.5.4 - version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/js-core: devDependencies: @@ -1063,28 +1261,28 @@ importers: specifier: workspace:* version: link:../vite-plugins '@types/node': - specifier: 25.4.0 + specifier: 'catalog:' version: 25.4.0 '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-plugin-dts: - specifier: 4.5.4 - version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/logger: dependencies: pino: - specifier: 10.3.1 + specifier: 'catalog:' version: 10.3.1 pino-opentelemetry-transport: specifier: 3.0.0 @@ -1093,7 +1291,7 @@ importers: specifier: 13.1.3 version: 13.1.3 zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 devDependencies: '@formbricks/config-eslint': @@ -1103,20 +1301,20 @@ importers: specifier: workspace:* version: link:../config-typescript '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) rimraf: - specifier: 6.1.3 + specifier: 'catalog:' version: 6.1.3 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/storage: dependencies: @@ -1140,61 +1338,61 @@ importers: specifier: workspace:* version: link:../config-typescript '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/survey-ui: dependencies: '@formkit/auto-animate': - specifier: 0.9.0 + specifier: 'catalog:' version: 0.9.0 '@radix-ui/react-checkbox': - specifier: 1.3.3 + specifier: 'catalog:' version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-dropdown-menu': - specifier: 2.1.16 + specifier: 'catalog:' version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-popover': - specifier: 1.1.15 + specifier: 'catalog:' version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-progress': specifier: 1.1.8 version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-radio-group': - specifier: 1.3.8 + specifier: 'catalog:' version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-slot': - specifier: 1.2.4 + specifier: 'catalog:' version: 1.2.4(@types/react@19.2.14)(react@19.2.6) class-variance-authority: - specifier: 0.7.1 + specifier: 'catalog:' version: 0.7.1 clsx: - specifier: 2.1.1 + specifier: 'catalog:' version: 2.1.1 date-fns: - specifier: 4.1.0 + specifier: 'catalog:' version: 4.1.0 isomorphic-dompurify: - specifier: 3.9.0 + specifier: 'catalog:' version: 3.9.0(@noble/hashes@2.0.1) lucide-react: - specifier: 0.577.0 + specifier: 'catalog:' version: 0.577.0(react@19.2.6) react-day-picker: - specifier: 9.14.0 + specifier: 'catalog:' version: 9.14.0(react@19.2.6) tailwind-merge: - specifier: 3.5.0 + specifier: 'catalog:' version: 3.5.0 devDependencies: '@formbricks/config-eslint': @@ -1204,65 +1402,65 @@ importers: specifier: workspace:* version: link:../config-typescript '@storybook/react': - specifier: 10.3.6 + specifier: 'catalog:' version: 10.3.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) '@storybook/react-vite': - specifier: 10.3.6 - version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) + specifier: 'catalog:' + version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) '@tailwindcss/postcss': - specifier: 4.2.4 - version: 4.2.4 + specifier: 'catalog:' + version: 4.3.1 '@tailwindcss/vite': - specifier: 4.2.4 - version: 4.2.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.3.1(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@testing-library/react': - specifier: 16.3.2 + specifier: 'catalog:' version: 16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/react': - specifier: 19.2.14 + specifier: 'catalog:' version: 19.2.14 '@types/react-dom': - specifier: 19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': - specifier: 5.1.4 - version: 5.1.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 5.1.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) jsdom: - specifier: 29.1.1 + specifier: 'catalog:' version: 29.1.1(@noble/hashes@2.0.1) preact: - specifier: 10.29.1 + specifier: 'catalog:' version: 10.29.1 react: - specifier: 19.2.6 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: 19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) rimraf: - specifier: 6.1.3 + specifier: 'catalog:' version: 6.1.3 tailwindcss: - specifier: 4.2.4 - version: 4.2.4 + specifier: 'catalog:' + version: 4.3.1 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-plugin-dts: - specifier: 4.5.4 - version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vite-tsconfig-paths: - specifier: 6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/surveys: dependencies: @@ -1273,22 +1471,22 @@ importers: specifier: workspace:* version: link:../survey-ui i18next: - specifier: 25.8.20 + specifier: 'catalog:' version: 25.8.20(typescript@5.9.3) i18next-icu: - specifier: 2.4.3 + specifier: 'catalog:' version: 2.4.3(intl-messageformat@10.7.18) isomorphic-dompurify: - specifier: 3.9.0 + specifier: 'catalog:' version: 3.9.0(@noble/hashes@2.0.1) preact: - specifier: 10.29.1 + specifier: 'catalog:' version: 10.29.1 react-i18next: - specifier: 16.5.8 + specifier: 'catalog:' version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) tailwind-merge: - specifier: 3.5.0 + specifier: 'catalog:' version: 3.5.0 devDependencies: '@formbricks/config-eslint': @@ -1305,18 +1503,18 @@ importers: version: link:../types '@preact/preset-vite': specifier: 2.10.5 - version: 2.10.5(@babel/core@7.29.6)(preact@10.29.1)(rollup@4.59.0)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 2.10.5(@babel/core@7.29.6)(preact@10.29.1)(rollup@4.59.0)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@tailwindcss/postcss': - specifier: 4.2.4 - version: 4.2.4 + specifier: 'catalog:' + version: 4.3.1 '@testing-library/preact': specifier: 3.2.4 version: 3.2.4(preact@10.29.1) '@types/node': - specifier: 25.4.0 + specifier: 'catalog:' version: 25.4.0 '@types/react': - specifier: 19.2.14 + specifier: 'catalog:' version: 19.2.14 autoprefixer: specifier: 10.4.27 @@ -1325,10 +1523,10 @@ importers: specifier: 9.2.4 version: 9.2.4 cross-env: - specifier: 10.1.0 + specifier: 'catalog:' version: 10.1.0 dotenv-cli: - specifier: 11.0.0 + specifier: 'catalog:' version: 11.0.0 fake-indexeddb: specifier: 6.2.5 @@ -1343,22 +1541,22 @@ importers: specifier: 7.0.1 version: 7.0.1(rolldown@1.0.3)(rollup@4.59.0) tailwindcss: - specifier: 4.2.4 - version: 4.2.4 + specifier: 'catalog:' + version: 4.3.1 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-tsconfig-paths: - specifier: 6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 packages/types: @@ -1367,17 +1565,17 @@ importers: specifier: 7.1.0 version: 7.1.0 zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 zod-openapi: - specifier: 5.4.6 + specifier: 'catalog:' version: 5.4.6(zod@4.3.6) devDependencies: '@formbricks/config-typescript': specifier: workspace:* version: link:../config-typescript typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 packages/vite-plugins: @@ -1392,19 +1590,19 @@ importers: specifier: 8.5.23 version: 8.5.23 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 - version: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + specifier: 'catalog:' + version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/workflows: dependencies: zod: - specifier: 4.3.6 + specifier: 'catalog:' version: 4.3.6 devDependencies: '@formbricks/config-eslint': @@ -1414,31 +1612,31 @@ importers: specifier: workspace:* version: link:../config-typescript '@types/js-yaml': - specifier: 4.0.9 + specifier: 'catalog:' version: 4.0.9 '@vitest/coverage-v8': - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) js-yaml: - specifier: 4.3.1 + specifier: 'catalog:' version: 4.3.1 publint: specifier: 0.3.21 version: 0.3.21 rimraf: - specifier: 6.1.3 + specifier: 'catalog:' version: 6.1.3 typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 vite: - specifier: 7.3.5 + specifier: 'catalog:' version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-plugin-dts: - specifier: 4.5.4 + specifier: 'catalog:' version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: - specifier: 4.1.6 + specifier: 'catalog:' version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -2070,14 +2268,6 @@ packages: resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -5891,79 +6081,39 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' - '@tailwindcss/node@4.2.4': - resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} - '@tailwindcss/oxide-android-arm64@4.2.4': - resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - '@tailwindcss/oxide-android-arm64@4.3.1': resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.4': - resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - '@tailwindcss/oxide-darwin-arm64@4.3.1': resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.4': - resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.1': resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.4': - resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - '@tailwindcss/oxide-freebsd-x64@4.3.1': resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': - resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': - resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} engines: {node: '>= 20'} @@ -5971,13 +6121,6 @@ packages: os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': - resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} @@ -5985,13 +6128,6 @@ packages: os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': - resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} @@ -5999,13 +6135,6 @@ packages: os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.4': - resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} @@ -6013,18 +6142,6 @@ packages: os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.4': - resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} engines: {node: '>=14.0.0'} @@ -6037,41 +6154,22 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': - resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': - resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.4': - resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} - engines: {node: '>= 20'} - '@tailwindcss/oxide@4.3.1': resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.2.4': - resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} - '@tailwindcss/postcss@4.3.1': resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} @@ -6080,8 +6178,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.4': - resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + '@tailwindcss/vite@4.3.1': + resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -7514,9 +7612,6 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -7986,10 +8081,6 @@ packages: resolution: {integrity: sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.20.0: - resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -8155,12 +8246,6 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-hooks@7.1.1: resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} @@ -11491,16 +11576,9 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' - tailwindcss@4.2.4: - resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -11598,10 +11676,6 @@ packages: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -13898,16 +13972,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -14568,6 +14632,14 @@ snapshots: optionalDependencies: typescript: 5.9.3 + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + glob: 13.0.6 + react-docgen-typescript: 2.4.0(typescript@5.9.3) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + optionalDependencies: + typescript: 5.9.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -16127,7 +16199,8 @@ snapshots: '@otplib/plugin-crypto': 12.0.1 '@otplib/plugin-thirty-two': 12.0.1 - '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.133.0': + optional: true '@paralleldrive/cuid2@2.3.1': dependencies: @@ -16180,19 +16253,19 @@ snapshots: '@posthog/types@1.404.0': {} - '@preact/preset-vite@2.10.5(@babel/core@7.29.6)(preact@10.29.1)(rollup@4.59.0)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.6)(preact@10.29.1)(rollup@4.59.0)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.6 '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.6) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.6) - '@prefresh/vite': 2.4.11(preact@10.29.1)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + '@prefresh/vite': 2.4.11(preact@10.29.1)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.6) debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) - vite-prerender-plugin: 0.5.12(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + vite-prerender-plugin: 0.5.12(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) zimmerframe: 1.1.4 transitivePeerDependencies: - preact @@ -16209,7 +16282,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.11(preact@10.29.1)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': + '@prefresh/vite@2.4.11(preact@10.29.1)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.6 '@prefresh/babel-plugin': 0.5.2 @@ -16217,7 +16290,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.29.1 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -16987,7 +17060,7 @@ snapshots: dependencies: '@react-email/text': 0.1.6(react@19.2.6) react: 19.2.6 - tailwindcss: 4.2.4 + tailwindcss: 4.3.1 optionalDependencies: '@react-email/body': 0.3.0(react@19.2.6) '@react-email/button': 0.2.1(react@19.2.6) @@ -17208,7 +17281,8 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true - '@rolldown/pluginutils@1.0.0': {} + '@rolldown/pluginutils@1.0.0': + optional: true '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -18155,10 +18229,10 @@ snapshots: axe-core: 4.11.0 storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': + '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.6) - '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) '@storybook/icons': 2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@storybook/react-dom-shim': 10.3.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) react: 19.2.6 @@ -18194,12 +18268,12 @@ snapshots: - rollup - webpack - '@storybook/builder-vite@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': + '@storybook/builder-vite@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': dependencies: - '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) ts-dedent: 2.2.0 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -18215,14 +18289,14 @@ snapshots: vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) webpack: 5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.23) - '@storybook/csf-plugin@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': + '@storybook/csf-plugin@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': dependencies: storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 rollup: 4.59.0 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) webpack: 5.105.4(esbuild@0.28.1)(lightningcss@1.32.0) '@storybook/global@5.0.0': {} @@ -18260,11 +18334,11 @@ snapshots: - typescript - webpack - '@storybook/react-vite@10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': + '@storybook/react-vite@10.3.6(esbuild@0.28.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) + '@storybook/builder-vite': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)) '@storybook/react': 10.3.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -18274,7 +18348,7 @@ snapshots: resolve: 1.22.11 storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tsconfig-paths: 4.2.0 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -18327,16 +18401,6 @@ snapshots: mini-svg-data-uri: 1.4.4 tailwindcss: 4.3.1 - '@tailwindcss/node@4.2.4': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.0 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.4 - '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -18347,93 +18411,42 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.3.1 - '@tailwindcss/oxide-android-arm64@4.2.4': - optional: true - '@tailwindcss/oxide-android-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.4': - optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.4': - optional: true - '@tailwindcss/oxide-darwin-x64@4.3.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.4': - optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': - optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': - optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': - optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': - optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.4': - optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.4': - optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': - optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': - optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': optional: true - '@tailwindcss/oxide@4.2.4': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-x64': 4.2.4 - '@tailwindcss/oxide-freebsd-x64': 4.2.4 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-x64-musl': 4.2.4 - '@tailwindcss/oxide-wasm32-wasi': 4.2.4 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/oxide@4.3.1': optionalDependencies: '@tailwindcss/oxide-android-arm64': 4.3.1 @@ -18449,14 +18462,6 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - '@tailwindcss/postcss@4.2.4': - dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.4 - '@tailwindcss/oxide': 4.2.4 - postcss: 8.5.23 - tailwindcss: 4.2.4 - '@tailwindcss/postcss@4.3.1': dependencies: '@alloc/quick-lru': 5.2.0 @@ -18470,12 +18475,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.1 - '@tailwindcss/vite@4.2.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.1(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@tailwindcss/node': 4.2.4 - '@tailwindcss/oxide': 4.2.4 - tailwindcss: 4.2.4 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + tailwindcss: 4.3.1 + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) '@tanstack/query-core@5.99.2': {} @@ -18521,7 +18526,7 @@ snapshots: '@testing-library/react@16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@testing-library/dom': 8.20.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -18594,7 +18599,7 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -18827,8 +18832,8 @@ snapshots: '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -18836,8 +18841,8 @@ snapshots: '@typescript-eslint/project-service@8.59.3(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -19071,6 +19076,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@5.1.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.6) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -19130,14 +19147,6 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.1.6(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.6 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.1.6(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 @@ -19145,6 +19154,7 @@ snapshots: magic-string: 0.30.21 optionalDependencies: vite: 8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + optional: true '@vitest/pretty-format@3.2.4': dependencies: @@ -20084,8 +20094,6 @@ snapshots: confbox@0.1.8: {} - confbox@0.2.2: {} - confbox@0.2.4: {} console-control-strings@1.1.0: @@ -20522,11 +20530,6 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.20.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -20816,10 +20819,6 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)): - dependencies: - eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.6 @@ -22697,7 +22696,7 @@ snapshots: dependencies: citty: 0.2.2 pathe: 2.0.3 - tinyexec: 1.1.2 + tinyexec: 1.2.4 oas-kit-common@1.0.8: dependencies: @@ -23086,7 +23085,7 @@ snapshots: pkg-types@2.3.0: dependencies: - confbox: 0.2.2 + confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3 @@ -23404,7 +23403,7 @@ snapshots: dependencies: '@babel/core': 7.29.6 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -23453,7 +23452,7 @@ snapshots: react-error-boundary@6.0.0(react@19.2.6): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 react: 19.2.6 react-grid-layout@2.2.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -23480,7 +23479,7 @@ snapshots: react-i18next@16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 html-parse-stringify: 3.0.1 i18next: 25.8.20(typescript@5.9.3) react: 19.2.6 @@ -23814,6 +23813,7 @@ snapshots: '@rolldown/binding-wasm32-wasi': 1.0.3 '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + optional: true rollup-plugin-visualizer@7.0.1(rolldown@1.0.3)(rollup@4.59.0): dependencies: @@ -24325,7 +24325,7 @@ snapshots: esbuild: 0.28.1 open: 10.2.0 recast: 0.23.11 - semver: 7.8.0 + semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.6) ws: 8.21.0 optionalDependencies: @@ -24510,12 +24510,8 @@ snapshots: dependencies: tailwindcss: 4.3.1 - tailwindcss@4.2.4: {} - tailwindcss@4.3.1: {} - tapable@2.3.0: {} - tapable@2.3.3: {} tar-fs@2.1.4: @@ -24606,11 +24602,6 @@ snapshots: tinyexec@1.2.4: {} - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -24980,25 +24971,6 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-dts@4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): - dependencies: - '@microsoft/api-extractor': 7.57.6(@types/node@25.4.0) - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@volar/typescript': 2.4.27 - '@vue/language-core': 2.2.0(typescript@5.9.3) - compare-versions: 6.1.1 - debug: 4.4.3 - kolorist: 1.8.0 - local-pkg: 1.1.2 - magic-string: 0.30.21 - typescript: 5.9.3 - optionalDependencies: - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@types/node' - - rollup - - supports-color - vite-plugin-dts@4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.57.6(@types/node@25.4.0) @@ -25018,7 +24990,7 @@ snapshots: - rollup - supports-color - vite-prerender-plugin@0.5.12(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): + vite-prerender-plugin@0.5.12(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -25026,7 +24998,7 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: @@ -25038,6 +25010,16 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.28.1 @@ -25045,7 +25027,7 @@ snapshots: picomatch: 4.0.4 postcss: 8.5.23 rollup: 4.59.0 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.4.0 fsevents: 2.3.3 @@ -25062,7 +25044,7 @@ snapshots: picomatch: 4.0.4 postcss: 8.5.23 rollup: 4.59.0 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.4.0 fsevents: 2.3.3 @@ -25072,22 +25054,6 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 - vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.23 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 25.4.0 - esbuild: 0.28.1 - fsevents: 2.3.3 - jiti: 2.6.1 - terser: 5.47.1 - tsx: 4.21.0 - yaml: 2.9.0 - vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -25103,6 +25069,7 @@ snapshots: terser: 5.47.1 tsx: 4.21.0 yaml: 2.9.0 + optional: true vitest-mock-extended@3.1.1(typescript@5.9.3)(vitest@4.1.6): dependencies: @@ -25127,7 +25094,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) @@ -25158,7 +25125,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) @@ -25172,37 +25139,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.6 - '@vitest/runner': 4.1.6 - '@vitest/snapshot': 4.1.6 - '@vitest/spy': 4.1.6 - '@vitest/utils': 4.1.6 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 25.4.0 - '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) - happy-dom: 20.8.9 - jsdom: 29.1.1(@noble/hashes@2.0.1) - transitivePeerDependencies: - - msw - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 @@ -25220,7 +25156,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) @@ -25233,6 +25169,7 @@ snapshots: jsdom: 29.1.1(@noble/hashes@2.0.1) transitivePeerDependencies: - msw + optional: true void-elements@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bd321f139e85..32f852632ae2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,107 @@ allowBuilds: unrs-resolver: true "@prisma/client": true +# Single source of truth for the version of every dependency used by two or more +# workspaces. Packages reference these as `"typescript": "catalog:"` and never as a +# literal version, so a shared dependency cannot drift between packages: bumping one +# means editing the entry here, which changes every consumer at once. +# +# `scripts/check-catalog.mjs` (run by `pnpm lint`) enforces both halves of that: it +# fails if a workspace declares a literal version for a name listed here, and if a +# dependency is declared by 2+ workspaces without being listed here at all. +# +# Deps with a single consumer deliberately stay in their own package.json — a catalog +# entry with one referent is indirection for nothing. `saveExact: true` (below) is why +# every entry is a bare exact version; the sole exception in the workspace is +# packages/survey-ui's react/react-dom `peerDependencies`, which are intentionally a +# `^19.0.0` range (a published package's peer range, not a pinned install) and are +# therefore excluded from the catalog rather than collapsed into the exact version. +catalog: + # --- build & test toolchain --- + typescript: 5.9.3 + # Vite stays on 7.x. Vite 8 replaces Rollup with rolldown, and @vitejs/plugin-react + # has no release accepting both majors (5.1.4 caps at vite 7; 6.1.0 requires ^8.0.0 + # exclusively plus three new peers), while vite-prerender-plugin — pulled in by + # @preact/preset-vite for packages/surveys — caps at 7.x. Moving the repo to Vite 8 + # is its own migration, tracked separately; see ENG-1689's follow-ups. + # Note: the lockfile still resolves a vite 8 for `@vitest/eslint-plugin`, which declares + # `vitest` as an OPTIONAL peer with range `*`; `autoInstallPeers` then materialises the + # newest vitest, whose own vite peer picks 8. That predates this catalog, no importer + # references it, and nothing builds with it — every workspace resolves 7.3.5. + vite: 7.3.5 + vitest: 4.1.6 + "@vitest/coverage-v8": 4.1.6 + vite-plugin-dts: 4.5.4 + vite-tsconfig-paths: 6.1.1 + "@vitejs/plugin-react": 5.1.4 + jsdom: 29.1.1 + postcss: 8.5.23 + rimraf: 6.1.3 + tsx: 4.21.0 + dotenv: 17.3.1 + dotenv-cli: 11.0.0 + cross-env: 10.1.0 + glob: 13.0.6 + # Read by scripts/check-catalog.mjs; also a packages/workflows dev dep. + js-yaml: 4.3.1 + "@playwright/test": 1.58.2 + "@testing-library/react": 16.3.2 + # --- lint & format --- + prettier: 3.8.3 + "@trivago/prettier-plugin-sort-imports": 6.0.2 + prettier-plugin-sort-json: 4.2.0 + prettier-plugin-tailwindcss: 0.7.4 + "@eslint/js": 9.39.5 + globals: 16.5.0 + typescript-eslint: 8.63.0 + eslint-plugin-react-hooks: 7.1.1 + # --- types --- + "@types/js-yaml": 4.0.9 + "@types/node": 25.4.0 + "@types/react": 19.2.14 + "@types/react-dom": 19.2.3 + # --- storybook --- + storybook: 10.3.6 + "@storybook/react": 10.3.6 + "@storybook/react-vite": 10.3.6 + # --- ui runtime shared by apps/web, survey-ui, surveys and email --- + react: 19.2.6 + react-dom: 19.2.6 + preact: 10.29.1 + tailwindcss: 4.3.1 + "@tailwindcss/postcss": 4.3.1 + "@tailwindcss/vite": 4.3.1 + clsx: 2.1.1 + tailwind-merge: 3.5.0 + class-variance-authority: 0.7.1 + lucide-react: 0.577.0 + date-fns: 4.1.0 + isomorphic-dompurify: 3.9.0 + "@formkit/auto-animate": 0.9.0 + react-day-picker: 9.14.0 + "@radix-ui/react-checkbox": 1.3.3 + "@radix-ui/react-dropdown-menu": 2.1.16 + "@radix-ui/react-popover": 1.1.15 + "@radix-ui/react-radio-group": 1.3.8 + "@radix-ui/react-slot": 1.2.4 + i18next: 25.8.20 + i18next-icu: 2.4.3 + react-i18next: 16.5.8 + # --- server runtime --- + zod: 4.3.6 + zod-openapi: 5.4.6 + "@prisma/client": 7.8.0 + # redis 5.x. apps/web was on 4.7.1 while @formbricks/cache was on 5.11.0, and because + # apps/web has no nested copy its `import type { RedisClientType } from "redis"` in + # lib/cache/index.ts resolved to the hoisted v4 type — then used to describe the v5 + # client that getCacheService() actually returns. Aligning here removes that crossover. + redis: 5.11.0 + pino: 10.3.1 + bcryptjs: 3.0.3 + uuid: 14.0.0 + "@paralleldrive/cuid2": 2.3.1 + ai: 6.0.177 + # Security overrides for transitive dependencies that still fail audit without a pin. # Remove each override once its upstream dependency chain adopts a patched version. # diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs new file mode 100755 index 000000000000..b289aef900c2 --- /dev/null +++ b/scripts/check-catalog.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Enforces the pnpm catalog in pnpm-workspace.yaml as the single source of truth for +// the version of every dependency shared by two or more workspaces. +// Runs as part of `pnpm lint` (root package.json), alongside `pnpm api:v3:lint`. +// +// Two rules, both reported together so one run lists every violation: +// +// 1. A workspace must not declare a literal version for a name the catalog lists. +// Writing "typescript": "5.9.4" next to a catalog holding 5.9.3 is exactly the +// drift the catalog exists to prevent, and hoisting hides it until it breaks. +// +// 2. A dependency declared by 2+ workspaces must be in the catalog. Without this +// half, a new shared dependency simply never gets catalogued and rule 1 has +// nothing to say about it. +// +// peerDependencies are treated differently on purpose: a peer *range* is a +// compatibility declaration for consumers, not an install pin, so it is legitimately +// looser than the catalog (packages/survey-ui declares react "^19.0.0" while pinning +// 19.2.6 to build against). Ranges are therefore exempt from both rules; an +// exact-pinned peer is not, since that is a version like any other. +// +// The workspace list is derived from pnpm-workspace.yaml's own globs — never hardcode +// it, or a newly added package silently escapes both rules. +import yaml from "js-yaml"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DEP_BLOCKS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; + +// A single pinned version — the only kind of specifier the catalog can replace. Tested +// positively rather than by listing range syntaxes: a range has too many spellings to +// enumerate (`^1`, `>=1 <2`, `1.x`, `1.2`, `1.2.3 - 2.0.0`, `*`, a dist-tag, empty), and +// missing one would report a legitimate peer range as a violation. +// The `=` comparator is optional in semver and means the same thing, so `=1.2.3` is a pin +// like `1.2.3` and must be enforced the same way; pnpm even preserves the form on update. +const isExactVersion = (spec) => /^=?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(spec); + +// Specifiers that resolve through something other than the registry, so there is no version +// to align. `catalog:` is deliberately absent — it is validated, not skipped (see below). +const isNotAVersion = (spec) => + spec.startsWith("workspace:") || + spec.startsWith("file:") || + spec.startsWith("link:") || + spec.startsWith("npm:") || + spec.startsWith("git"); + +// `catalog:` uses the default catalog; `catalog:` a named one. +const CATALOG_REF = /^catalog:(.*)$/; + +// Deliberate exceptions to rule 2: a dependency shared by 2+ workspaces that must NOT be +// catalogued. Add an entry only with a comment saying why the two consumers are meant to +// hold different versions — if they are meant to match, the fix is a catalog entry, not +// an exemption. Empty today, and that is the healthy state. +const ALLOW_UNCATALOGED = new Set([]); + +/** + * Expand pnpm-workspace.yaml's `packages` globs to directories that hold a package.json. + * + * Only the two shapes this repo uses are supported: `dir/*` and a literal directory. Anything + * else — `dir/**`, a `!` exclusion, a brace or a `?` — throws instead of being skipped. Skipping + * silently would be the worst possible failure for a guard: coverage collapses to nothing while + * the check still reports success, so a later edit to the globs would quietly switch it off. + * If a broader glob is ever genuinely needed, resolve the workspaces with pnpm itself + * (`pnpm -r exec` / `@pnpm/workspace.find-packages`) rather than widening this parser. + */ +function resolveWorkspaceDirs(globs) { + const dirs = new Set(); + for (const pattern of globs) { + if (pattern.endsWith("/*") && !/[!*?{}[\]]/.test(pattern.slice(0, -2))) { + const prefix = pattern.slice(0, -2); + const parent = join(REPO_ROOT, prefix); + if (!existsSync(parent)) { + throw new Error( + `pnpm-workspace.yaml lists the workspace glob "${pattern}", but ${prefix}/ does not exist.` + ); + } + for (const entry of readdirSync(parent, { withFileTypes: true })) { + if (entry.isDirectory() && existsSync(join(parent, entry.name, "package.json"))) { + dirs.add(join(prefix, entry.name)); + } + } + } else if (!/[!*?{}[\]]/.test(pattern) && existsSync(join(REPO_ROOT, pattern, "package.json"))) { + dirs.add(pattern); + } else { + throw new Error( + `pnpm-workspace.yaml lists the workspace glob "${pattern}", which this check cannot expand.\n` + + `Only "dir/*" and literal directories are supported. Teach resolveWorkspaceDirs in\n` + + `scripts/check-catalog.mjs about the new shape — do not leave it unhandled, or the check\n` + + `silently stops covering those packages while still reporting success.` + ); + } + } + if (dirs.size === 0) { + throw new Error( + `pnpm-workspace.yaml's \`packages\` globs matched no workspace at all — refusing to report success.` + ); + } + // The root manifest is not matched by the globs but declares dependencies like any member. + return ["", ...[...dirs].sort()]; +} + +const workspaceFile = yaml.load(readFileSync(join(REPO_ROOT, "pnpm-workspace.yaml"), "utf8")); +const catalog = workspaceFile.catalog ?? {}; +const namedCatalogs = workspaceFile.catalogs ?? {}; +if (Object.keys(catalog).length === 0) { + console.error("pnpm-workspace.yaml has no `catalog:` block — nothing to enforce."); + process.exit(1); +} + +const literalViolations = []; +const brokenRefs = []; +const declaredBy = new Map(); // dep name -> Map + +for (const dir of resolveWorkspaceDirs(workspaceFile.packages ?? [])) { + const manifestPath = join(REPO_ROOT, dir, "package.json"); + const label = dir === "" ? "package.json" : `${dir}/package.json`; + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + + for (const block of DEP_BLOCKS) { + for (const [name, spec] of Object.entries(manifest[block] ?? {})) { + if (isNotAVersion(spec)) continue; + + // A `catalog:` reference that names nothing is a typo pnpm only catches at install + // time; report it here, where the rest of the contract is checked. + const catalogRef = CATALOG_REF.exec(spec); + if (catalogRef) { + const group = catalogRef[1]; + if (!group && !(name in catalog)) { + brokenRefs.push(` ${label}: ${block}.${name} is "catalog:" but the catalog has no ${name} entry`); + } else if (group && !(namedCatalogs[group] ?? {})[name]) { + brokenRefs.push( + ` ${label}: ${block}.${name} is "${spec}" but catalogs.${group} has no ${name} entry` + ); + } + continue; + } + + // A peer range is a compatibility declaration for consumers, not an install pin, so it + // may legitimately be looser than the catalog. Only an exactly-pinned peer is enforced. + if (block === "peerDependencies" && !isExactVersion(spec)) continue; + + if (name in catalog) { + literalViolations.push( + ` ${label}: ${block}.${name} is "${spec}" — use "catalog:" (the catalog holds ${catalog[name]})` + ); + } else { + if (!declaredBy.has(name)) declaredBy.set(name, new Map()); + declaredBy.get(name).set(label, spec); + } + } + } +} + +const uncataloged = [...declaredBy.entries()] + .filter(([name, uses]) => uses.size >= 2 && !ALLOW_UNCATALOGED.has(name)) + .map(([name, uses]) => { + const where = [...uses.entries()].map(([label, spec]) => `${label} (${spec})`).join(", "); + return ` "${name}" is declared by ${uses.size} workspaces but is not in the catalog: ${where}`; + }); + +if (literalViolations.length === 0 && uncataloged.length === 0 && brokenRefs.length === 0) { + console.log(`✓ catalog check passed (${Object.keys(catalog).length} catalogued dependencies)`); + process.exit(0); +} + +if (literalViolations.length > 0) { + console.error(`\n✗ Literal version for a catalogued dependency (${literalViolations.length}):\n`); + console.error(literalViolations.join("\n")); + console.error(`\n Replace the version with "catalog:". To change the version itself, edit the`); + console.error(` entry in pnpm-workspace.yaml so every consumer moves together.`); +} + +if (uncataloged.length > 0) { + console.error(`\n✗ Shared dependency missing from the catalog (${uncataloged.length}):\n`); + console.error(uncataloged.join("\n")); + console.error(`\n Add it to the \`catalog:\` block in pnpm-workspace.yaml and point each`); + console.error(` consumer at "catalog:". If the versions are meant to differ, add the name to`); + console.error(` ALLOW_UNCATALOGED in this script with a comment explaining why.`); +} + +if (brokenRefs.length > 0) { + console.error(`\n✗ \`catalog:\` reference with no matching catalog entry (${brokenRefs.length}):\n`); + console.error(brokenRefs.join("\n")); + console.error(`\n Add the dependency to the referenced catalog in pnpm-workspace.yaml, or replace the`); + console.error(` reference with a version. pnpm would fail the install on this too.`); +} + +console.error(""); +process.exit(1); From 9872384772b9810127295087c66f30cdcb577afb Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:24:50 +0000 Subject: [PATCH 2/6] feat(charts): add chart display settings (#8884) --- apps/web/i18n.lock | 9 + apps/web/locales/de-DE.json | 13 +- apps/web/locales/en-US.json | 11 +- apps/web/locales/es-ES.json | 13 +- apps/web/locales/fr-FR.json | 13 +- apps/web/locales/hu-HU.json | 15 +- apps/web/locales/ja-JP.json | 13 +- apps/web/locales/nl-NL.json | 17 +- apps/web/locales/pt-BR.json | 15 +- apps/web/locales/pt-PT.json | 15 +- apps/web/locales/ro-RO.json | 17 +- apps/web/locales/ru-RU.json | 13 +- apps/web/locales/sv-SE.json | 13 +- apps/web/locales/tr-TR.json | 15 +- apps/web/locales/zh-Hans-CN.json | 13 +- apps/web/locales/zh-Hant-TW.json | 13 +- .../charts/components/breakdown-bars.tsx | 146 +++++++++++++++ .../charts/components/cartesian-chart.tsx | 173 +++++++++++++++--- .../components/chart-display-settings.tsx | 97 ++++++++++ .../charts/components/chart-preview.tsx | 5 + .../charts/components/chart-renderer.tsx | 89 ++++++--- .../charts/components/create-chart-view.tsx | 12 ++ .../analysis/charts/hooks/use-chart-dialog.ts | 16 +- .../analysis/charts/lib/chart-display.test.ts | 98 ++++++++++ .../ee/analysis/charts/lib/chart-display.ts | 43 +++++ .../analysis/charts/lib/chart-utils.test.ts | 122 ++++++++++++ .../ee/analysis/charts/lib/chart-utils.ts | 125 ++++++++++++- .../components/dashboard-detail-client.tsx | 7 +- .../components/dashboard-widget-data.tsx | 6 +- .../ui/components/options-switch/index.tsx | 25 ++- packages/types/analysis.ts | 4 + 31 files changed, 1067 insertions(+), 119 deletions(-) create mode 100644 apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx create mode 100644 apps/web/modules/ee/analysis/charts/components/chart-display-settings.tsx create mode 100644 apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts create mode 100644 apps/web/modules/ee/analysis/charts/lib/chart-display.ts diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 87a8df9fe439..3d82ba4e6007 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1583,12 +1583,14 @@ checksums: workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3 + workspace/analysis/charts/bar_direction: d78ce3e340da83ec3904a31a4e01b5cb workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813 workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00 workspace/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4 workspace/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b workspace/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e workspace/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0 + workspace/analysis/charts/chart_display_settings: 00e38d777382e98fab9049f5f85f91de workspace/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165 workspace/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4 workspace/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc @@ -1630,6 +1632,8 @@ checksums: workspace/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147 workspace/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022 workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595 + workspace/analysis/charts/distribution_segment_label: e75329715441ae0d1388a0aaa452b656 + workspace/analysis/charts/distribution_value_share: 954274ec06ca0aeafb52b68ef8a406f6 workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71 workspace/analysis/charts/edit_chart_title_named: 216b4226fb611b3694bc222026722845 @@ -1720,6 +1724,7 @@ checksums: workspace/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738 workspace/analysis/charts/group_by_description: bebcfe28bb315834aa3a307834acfbe0 workspace/analysis/charts/group_data: 55c0035773d8c6b7f4d96363a61cda82 + workspace/analysis/charts/horizontal_bars: 9f7e34591bab8b54fb713078677cf9d5 workspace/analysis/charts/is_not_set: 906801489132487ef457652af4835142 workspace/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa workspace/analysis/charts/language_value_unspecified: 85dba018c7aea3e0590a113361f419c5 @@ -1749,6 +1754,9 @@ checksums: workspace/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7 workspace/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95 workspace/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278 + workspace/analysis/charts/pie_display: eb53ad78765ddc4050d898a70f2fac68 + workspace/analysis/charts/pie_display_breakdown: 5a072a1240ff9b5d8246af085ef92192 + workspace/analysis/charts/pie_display_pie: 077915149770d6fb8bc73d743db4e901 workspace/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309 workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6 workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b @@ -1777,6 +1785,7 @@ checksums: workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84 workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190 + workspace/analysis/charts/vertical_bars: 408174fe449ea6f5457988dd218183be workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa workspace/analysis/dashboards/chart_duplicate_failed: 90d7166c85188b52f821c9d9f53ff8c4 workspace/analysis/dashboards/chart_duplicated: 52765f173bd6fd5382731f8e06e436e0 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 22a48bff9c78..05d0879c01f6 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -217,7 +217,6 @@ "code": "Code", "collapse_rows": "Zeilen einklappen", "column_n": "Spalte {n}", - "coming_soon": "Coming soon", "completed": "Abgeschlossen", "confirm": "Bestätigen", "connect": "Verbinden", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Bereits im Dashboard", "and_filter_logic": "UND", "apply_changes": "Änderungen übernehmen", + "bar_direction": "Balkenrichtung", "chart": "Diagramm", "chart_added_to_dashboard": "Diagramm zum Dashboard hinzugefügt!", "chart_data": "Diagrammdaten", "chart_data_tab": "Daten", "chart_deleted_successfully": "Diagramm erfolgreich gelöscht", "chart_deletion_error": "Diagramm konnte nicht gelöscht werden", + "chart_display_settings": "Diagramm-Anzeigeeinstellungen", "chart_duplicated_successfully": "Diagramm erfolgreich dupliziert", "chart_duplication_error": "Diagramm konnte nicht dupliziert werden", "chart_name": "Diagrammname", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?", "dimensions": "Dimensionen", "dimensions_toggle_description": "Gruppiere Daten nach Stimmung, Fragetyp und anderen Dimensionen.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.", "edit_chart_title": "Diagramm bearbeiten", "edit_chart_title_named": "„{name}“ bearbeiten", @@ -1784,6 +1787,7 @@ "group_by": "Gruppieren nach", "group_by_description": "Schlüssle deine Daten nach einer oder mehreren Dimensionen auf (die Reihenfolge ist wichtig).", "group_data": "Daten gruppieren", + "horizontal_bars": "Horizontale Balken", "is_not_set": "ist nicht festgelegt", "is_set": "ist festgelegt", "language_value_unspecified": "Nicht angegeben", @@ -1813,6 +1817,9 @@ "open_options": "Diagrammoptionen öffnen", "or_filter_logic": "ODER", "original": "Original", + "pie_display": "Anzeigen als", + "pie_display_breakdown": "Aufschlüsselungsbalken", + "pie_display_pie": "Kreisdiagramm", "please_enter_chart_name": "Bitte gib einen Diagrammnamen ein", "please_select_dashboard": "Bitte wähle ein Dashboard aus", "predefined_measures": "Vordefinierte Kennzahlen", @@ -1840,7 +1847,8 @@ "start_date": "Startdatum", "time_dimension": "Zeitdimension", "time_dimension_title": "Zeitbasierte Gruppierung hinzufügen", - "time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf." + "time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.", + "vertical_bars": "Vertikale Balken" }, "dashboards": { "add_count_charts": "{count} Diagramm(e) hinzufügen", @@ -3953,7 +3961,6 @@ "allowed_values": "Zulässige Werte: {values}", "api_ingestion": "API-Erfassung", "api_ingestion_settings_description": "Erstelle Feedback-Einträge über die Management API", - "api_ingestion_setup_description": "Nutze die REST API, um Feedback-Datensätze direkt an Formbricks zu senden. Die API-Ingestion-Docs enthalten den Endpunkt, die Payload-Struktur und Authentifizierungsdetails.", "auto_generated": "Automatisch generiert", "change_file": "Datei ändern", "clear_mapping": "Zuordnung löschen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 76f146b81f4f..5591fd5b0e74 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1646,12 +1646,14 @@ "already_on_dashboard": "Already on dashboard", "and_filter_logic": "AND", "apply_changes": "Apply Changes", + "bar_direction": "Bar direction", "chart": "Chart", "chart_added_to_dashboard": "Chart added to dashboard!", "chart_data": "Chart Data", "chart_data_tab": "Data", "chart_deleted_successfully": "Chart deleted successfully", "chart_deletion_error": "Failed to delete chart", + "chart_display_settings": "Chart display settings", "chart_duplicated_successfully": "Chart duplicated successfully", "chart_duplication_error": "Failed to duplicate chart", "chart_name": "Chart Name", @@ -1693,6 +1695,8 @@ "delete_chart_confirmation": "Are you sure you want to delete this chart?", "dimensions": "Dimensions", "dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "View and edit your chart configuration.", "edit_chart_title": "Edit Chart", "edit_chart_title_named": "Edit \"{name}\"", @@ -1783,6 +1787,7 @@ "group_by": "Group By", "group_by_description": "Break down your data by one or more dimensions (order matters).", "group_data": "Group data", + "horizontal_bars": "Horizontal bars", "is_not_set": "is not set", "is_set": "is set", "language_value_unspecified": "Not specified", @@ -1812,6 +1817,9 @@ "open_options": "Open chart options", "or_filter_logic": "OR", "original": "Original", + "pie_display": "Display as", + "pie_display_breakdown": "Breakdown bars", + "pie_display_pie": "Pie chart", "please_enter_chart_name": "Please enter a chart name", "please_select_dashboard": "Please select a dashboard", "predefined_measures": "Predefined Measures", @@ -1839,7 +1847,8 @@ "start_date": "Start date", "time_dimension": "Time Dimension", "time_dimension_title": "Add time-based grouping", - "time_dimension_toggle_description": "Monitor trends over time." + "time_dimension_toggle_description": "Monitor trends over time.", + "vertical_bars": "Vertical bars" }, "dashboards": { "add_count_charts": "Add {count} chart(s)", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index a22ea1507bec..c95b35fff425 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -217,7 +217,6 @@ "code": "Código", "collapse_rows": "Contraer filas", "column_n": "Columna {n}", - "coming_soon": "Coming soon", "completed": "Completado", "confirm": "Confirmar", "connect": "Conectar", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Ya está en el panel", "and_filter_logic": "Y", "apply_changes": "Aplicar cambios", + "bar_direction": "Dirección de las barras", "chart": "Gráfico", "chart_added_to_dashboard": "¡Gráfico añadido al panel de control!", "chart_data": "Datos del gráfico", "chart_data_tab": "Datos", "chart_deleted_successfully": "Gráfico eliminado correctamente", "chart_deletion_error": "Error al eliminar el gráfico", + "chart_display_settings": "Configuración de visualización del gráfico", "chart_duplicated_successfully": "Gráfico duplicado correctamente", "chart_duplication_error": "Error al duplicar el gráfico", "chart_name": "Nombre del gráfico", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?", "dimensions": "Dimensiones", "dimensions_toggle_description": "Agrupa los datos por sentimiento, tipo de pregunta y otras dimensiones.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Visualiza y edita la configuración de tu gráfico.", "edit_chart_title": "Editar gráfico", "edit_chart_title_named": "Editar \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Agrupar por", "group_by_description": "Desglosa tus datos por una o más dimensiones (el orden importa).", "group_data": "Agrupar datos", + "horizontal_bars": "Barras horizontales", "is_not_set": "no está establecido", "is_set": "está establecido", "language_value_unspecified": "No especificado", @@ -1813,6 +1817,9 @@ "open_options": "Abrir opciones del gráfico", "or_filter_logic": "O", "original": "Original", + "pie_display": "Mostrar como", + "pie_display_breakdown": "Barras de desglose", + "pie_display_pie": "Gráfico circular", "please_enter_chart_name": "Introduce un nombre para el gráfico", "please_select_dashboard": "Selecciona un panel de control", "predefined_measures": "Medidas predefinidas", @@ -1840,7 +1847,8 @@ "start_date": "Fecha de inicio", "time_dimension": "Dimensión temporal", "time_dimension_title": "Añadir agrupación temporal", - "time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo." + "time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.", + "vertical_bars": "Barras verticales" }, "dashboards": { "add_count_charts": "Añadir {count} gráfico(s)", @@ -3953,7 +3961,6 @@ "allowed_values": "Valores permitidos: {values}", "api_ingestion": "Ingesta de API", "api_ingestion_settings_description": "Crea registros de feedback usando la API de Gestión", - "api_ingestion_setup_description": "Utiliza la API REST para enviar registros de feedback directamente a Formbricks. La documentación de ingesta de API incluye el endpoint, la estructura del payload y los detalles de autenticación.", "auto_generated": "Generado automáticamente", "change_file": "Cambiar archivo", "clear_mapping": "Borrar asignación", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index de7b424092f5..ff4d1526a66a 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -217,7 +217,6 @@ "code": "Code", "collapse_rows": "Réduire les lignes", "column_n": "Colonne {n}", - "coming_soon": "Coming soon", "completed": "Terminé", "confirm": "Confirmer", "connect": "Connecter", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Déjà sur le tableau de bord", "and_filter_logic": "ET", "apply_changes": "Appliquer les modifications", + "bar_direction": "Direction des barres", "chart": "Graphique", "chart_added_to_dashboard": "Graphique ajouté au tableau de bord !", "chart_data": "Données du graphique", "chart_data_tab": "Données", "chart_deleted_successfully": "Graphique supprimé avec succès", "chart_deletion_error": "Échec de la suppression du graphique", + "chart_display_settings": "Paramètres d'affichage du graphique", "chart_duplicated_successfully": "Graphique dupliqué avec succès", "chart_duplication_error": "Échec de la duplication du graphique", "chart_name": "Nom du graphique", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique ?", "dimensions": "Dimensions", "dimensions_toggle_description": "Groupe les données par sentiment, type de question et autres dimensions.", + "distribution_segment_label": "{label} : {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Consultez et modifiez la configuration de votre graphique.", "edit_chart_title": "Modifier le graphique", "edit_chart_title_named": "Modifier \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Regrouper par", "group_by_description": "Décompose tes données selon une ou plusieurs dimensions (l'ordre compte).", "group_data": "Grouper les données", + "horizontal_bars": "Barres horizontales", "is_not_set": "n'est pas défini", "is_set": "est défini", "language_value_unspecified": "Non spécifié", @@ -1813,6 +1817,9 @@ "open_options": "Ouvrir les options du graphique", "or_filter_logic": "OU", "original": "Original", + "pie_display": "Afficher en tant que", + "pie_display_breakdown": "Barres de répartition", + "pie_display_pie": "Graphique en secteurs", "please_enter_chart_name": "Veuillez saisir un nom de graphique", "please_select_dashboard": "Veuillez sélectionner un tableau de bord", "predefined_measures": "Mesures prédéfinies", @@ -1840,7 +1847,8 @@ "start_date": "Date de début", "time_dimension": "Dimension temporelle", "time_dimension_title": "Ajouter un groupement temporel", - "time_dimension_toggle_description": "Surveille les tendances dans le temps." + "time_dimension_toggle_description": "Surveille les tendances dans le temps.", + "vertical_bars": "Barres verticales" }, "dashboards": { "add_count_charts": "Ajouter {count} graphique(s)", @@ -3953,7 +3961,6 @@ "allowed_values": "Valeurs autorisées : {values}", "api_ingestion": "Ingestion par API", "api_ingestion_settings_description": "Crée des retours en utilisant l'API de gestion", - "api_ingestion_setup_description": "Utilise l'API REST pour envoyer directement les retours d'expérience dans Formbricks. La documentation sur l'ingestion API inclut le point de terminaison, la structure de la charge utile et les détails d'authentification.", "auto_generated": "Généré automatiquement", "change_file": "Changer de fichier", "clear_mapping": "Effacer le mappage", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index d025bd888cf4..3e801da62805 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -217,7 +217,6 @@ "code": "Kód", "collapse_rows": "Sorok összecsukása", "column_n": "{n}. oszlop", - "coming_soon": "Coming soon", "completed": "Befejezve", "confirm": "Megerősítés", "connect": "Kapcsolódás", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Már a vezérlőpulton van", "and_filter_logic": "ÉS", "apply_changes": "Változtatások alkalmazása", + "bar_direction": "Oszlopok iránya", "chart": "Diagram", "chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!", - "chart_data": "Diagramadatok", + "chart_data": "Diagram adatai", "chart_data_tab": "Adatok", "chart_deleted_successfully": "A diagram sikeresen törölve", "chart_deletion_error": "Nem sikerült törölni a diagramot", + "chart_display_settings": "Diagram megjelenítési beállítások", "chart_duplicated_successfully": "A diagram sikeresen megkettőzve", "chart_duplication_error": "Nem sikerült kettőzni a diagramot", "chart_name": "Diagram neve", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Biztosan törölni szeretné ezt a diagramot?", "dimensions": "Dimenziók", "dimensions_toggle_description": "Adatok csoportosítása hangulat, kérdéstípus és egyéb dimenziók szerint.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Diagram beállításainak megtekintése és szerkesztése.", "edit_chart_title": "Diagram szerkesztése", "edit_chart_title_named": "„{name}“ szerkesztése", @@ -1784,6 +1787,7 @@ "group_by": "Csoportosítás", "group_by_description": "Bontsa le adatait egy vagy több dimenzió szerint (a sorrend számít).", "group_data": "Adatok csoportosítása", + "horizontal_bars": "Vízszintes oszlopok", "is_not_set": "nincs beállítva", "is_set": "be van állítva", "language_value_unspecified": "Nem megadott", @@ -1813,6 +1817,9 @@ "open_options": "Diagram beállításainak megnyitása", "or_filter_logic": "VAGY", "original": "Eredeti", + "pie_display": "Megjelenítés módja", + "pie_display_breakdown": "Lebontási sávok", + "pie_display_pie": "Kördiagram", "please_enter_chart_name": "Adjon meg egy diagramnevet", "please_select_dashboard": "Válasszon egy vezérlőpultot", "predefined_measures": "Előre meghatározott mérések", @@ -1840,7 +1847,8 @@ "start_date": "Kezdési dátum", "time_dimension": "Idődimenzió", "time_dimension_title": "Időalapú csoportosítás hozzáadása", - "time_dimension_toggle_description": "Időbeni trendek megfigyelése." + "time_dimension_toggle_description": "Időbeni trendek megfigyelése.", + "vertical_bars": "Függőleges oszlopok" }, "dashboards": { "add_count_charts": "{count} diagram hozzáadása", @@ -3953,7 +3961,6 @@ "allowed_values": "Engedélyezett értékek: {values}", "api_ingestion": "API-befogadás", "api_ingestion_settings_description": "Visszajelzési rekordok létrehozása a Management API használatával", - "api_ingestion_setup_description": "Használja a REST API-t, hogy visszajelzési rekordokat küldjön közvetlenül a Formbricksbe. Az API-befogadási dokumentáció tartalmazza a végpontot, az adatcsomag szerkezetét és a hitelesítési részleteket.", "auto_generated": "Automatikusan előállítva", "change_file": "Fájl megváltoztatása", "clear_mapping": "Leképezés törlése", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index f8ab8f5c8885..2d4f7629c35d 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -217,7 +217,6 @@ "code": "コード", "collapse_rows": "行を非表示", "column_n": "列 {n}", - "coming_soon": "Coming soon", "completed": "完了", "confirm": "確認", "connect": "接続", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "すでにダッシュボードに追加済み", "and_filter_logic": "AND", "apply_changes": "変更を適用", + "bar_direction": "棒の向き", "chart": "チャート", "chart_added_to_dashboard": "チャートをダッシュボードに追加しました!", "chart_data": "チャートデータ", "chart_data_tab": "データ", "chart_deleted_successfully": "チャートを削除しました", "chart_deletion_error": "チャートの削除に失敗しました", + "chart_display_settings": "グラフ表示設定", "chart_duplicated_successfully": "チャートを複製しました", "chart_duplication_error": "チャートの複製に失敗しました", "chart_name": "チャート名", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "このチャートを削除してもよろしいですか?", "dimensions": "ディメンション", "dimensions_toggle_description": "センチメント、質問タイプ、その他のディメンションでデータをグループ化します。", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "チャート設定を表示および編集します。", "edit_chart_title": "チャートを編集", "edit_chart_title_named": "「{name}」を編集", @@ -1784,6 +1787,7 @@ "group_by": "グループ化", "group_by_description": "1つ以上のディメンションでデータを分類できます(順序が重要です)。", "group_data": "データをグループ化", + "horizontal_bars": "横棒", "is_not_set": "設定されていない", "is_set": "設定されている", "language_value_unspecified": "未指定", @@ -1813,6 +1817,9 @@ "open_options": "チャートオプションを開く", "or_filter_logic": "OR", "original": "オリジナル", + "pie_display": "表示形式", + "pie_display_breakdown": "内訳バー", + "pie_display_pie": "円グラフ", "please_enter_chart_name": "チャート名を入力してください", "please_select_dashboard": "ダッシュボードを選択してください", "predefined_measures": "事前定義されたメジャー", @@ -1840,7 +1847,8 @@ "start_date": "開始日", "time_dimension": "時間ディメンション", "time_dimension_title": "時間ベースのグループ化を追加", - "time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。" + "time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。", + "vertical_bars": "縦棒" }, "dashboards": { "add_count_charts": "{count}個のグラフを追加", @@ -3953,7 +3961,6 @@ "allowed_values": "許可される値: {values}", "api_ingestion": "API取り込み", "api_ingestion_settings_description": "Management APIを使用してフィードバック記録を作成", - "api_ingestion_setup_description": "REST APIを使用して、フィードバックレコードをFormbricksに直接送信できます。APIインジェストのドキュメントには、エンドポイント、ペイロード形式、認証の詳細が含まれています。", "auto_generated": "自動生成", "change_file": "ファイルを変更", "clear_mapping": "マッピングをクリア", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index df5a3f8a57fc..7d14ed0f7364 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -217,7 +217,6 @@ "code": "Code", "collapse_rows": "Rijen samenvouwen", "column_n": "Kolom {n}", - "coming_soon": "Coming soon", "completed": "Voltooid", "confirm": "Bevestigen", "connect": "Verbinden", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Al op dashboard", "and_filter_logic": "EN", "apply_changes": "Wijzigingen toepassen", + "bar_direction": "Staafrichting", "chart": "Grafiek", "chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!", - "chart_data": "Grafiekdata", - "chart_data_tab": "Data", + "chart_data": "Grafiekgegevens", + "chart_data_tab": "Gegevens", "chart_deleted_successfully": "Grafiek succesvol verwijderd", "chart_deletion_error": "Verwijderen van grafiek mislukt", + "chart_display_settings": "Grafiekweergave-instellingen", "chart_duplicated_successfully": "Grafiek succesvol gedupliceerd", "chart_duplication_error": "Dupliceren van grafiek mislukt", "chart_name": "Grafieknaam", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?", "dimensions": "Dimensies", "dimensions_toggle_description": "Groepeer data op sentiment, vraagtype en andere dimensies.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.", "edit_chart_title": "Diagram bewerken", "edit_chart_title_named": "Bewerk \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Groeperen op", "group_by_description": "Splits je data op volgens een of meer dimensies (volgorde is belangrijk).", "group_data": "Data groeperen", + "horizontal_bars": "Horizontale staven", "is_not_set": "is niet ingesteld", "is_set": "is ingesteld", "language_value_unspecified": "Niet gespecificeerd", @@ -1813,6 +1817,9 @@ "open_options": "Open diagramopties", "or_filter_logic": "OF", "original": "Origineel", + "pie_display": "Weergeven als", + "pie_display_breakdown": "Uitgesplitste balken", + "pie_display_pie": "Taartdiagram", "please_enter_chart_name": "Voer een diagramnaam in", "please_select_dashboard": "Selecteer een dashboard", "predefined_measures": "Vooraf gedefinieerde metingen", @@ -1840,7 +1847,8 @@ "start_date": "Startdatum", "time_dimension": "Tijdsdimensie", "time_dimension_title": "Tijdgebaseerde groepering toevoegen", - "time_dimension_toggle_description": "Volg trends over tijd." + "time_dimension_toggle_description": "Volg trends over tijd.", + "vertical_bars": "Verticale staven" }, "dashboards": { "add_count_charts": "{count} grafiek(en) toevoegen", @@ -3953,7 +3961,6 @@ "allowed_values": "Toegestane waarden: {values}", "api_ingestion": "API-inname", "api_ingestion_settings_description": "Maak feedbackgegevens aan via de Management API", - "api_ingestion_setup_description": "Gebruik de REST API om feedbackgegevens rechtstreeks naar Formbricks te sturen. De API-ingestiedocumentatie bevat het endpoint, de payload-structuur en authenticatiegegevens.", "auto_generated": "Automatisch gegenereerd", "change_file": "Bestand wijzigen", "clear_mapping": "Mapping wissen", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 22c7d18020aa..43a340b69705 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -217,7 +217,6 @@ "code": "Código", "collapse_rows": "Recolher linhas", "column_n": "Coluna {n}", - "coming_soon": "Coming soon", "completed": "Concluído", "confirm": "Confirmar", "connect": "Conectar", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Já está no painel", "and_filter_logic": "E", "apply_changes": "Aplicar alterações", + "bar_direction": "Direção das barras", "chart": "Gráfico", "chart_added_to_dashboard": "Gráfico adicionado ao painel!", - "chart_data": "Dados do gráfico", + "chart_data": "Dados do Gráfico", "chart_data_tab": "Dados", "chart_deleted_successfully": "Gráfico excluído com sucesso", "chart_deletion_error": "Falha ao excluir gráfico", + "chart_display_settings": "Configurações de exibição do gráfico", "chart_duplicated_successfully": "Gráfico duplicado com sucesso", "chart_duplication_error": "Falha ao duplicar gráfico", "chart_name": "Nome do gráfico", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?", "dimensions": "Dimensões", "dimensions_toggle_description": "Agrupe dados por sentimento, tipo de pergunta e outras dimensões.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Visualize e edite a configuração do seu gráfico.", "edit_chart_title": "Editar gráfico", "edit_chart_title_named": "Editar \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Agrupar por", "group_by_description": "Divida seus dados por uma ou mais dimensões (a ordem importa).", "group_data": "Agrupar dados", + "horizontal_bars": "Barras horizontais", "is_not_set": "não está definido", "is_set": "está definido", "language_value_unspecified": "Não especificado", @@ -1813,6 +1817,9 @@ "open_options": "Abrir opções do gráfico", "or_filter_logic": "OU", "original": "Original", + "pie_display": "Exibir como", + "pie_display_breakdown": "Barras de detalhamento", + "pie_display_pie": "Gráfico de pizza", "please_enter_chart_name": "Por favor, insira um nome para o gráfico", "please_select_dashboard": "Por favor, selecione um painel", "predefined_measures": "Medidas predefinidas", @@ -1840,7 +1847,8 @@ "start_date": "Data inicial", "time_dimension": "Dimensão temporal", "time_dimension_title": "Adicionar agrupamento por tempo", - "time_dimension_toggle_description": "Monitore tendências ao longo do tempo." + "time_dimension_toggle_description": "Monitore tendências ao longo do tempo.", + "vertical_bars": "Barras verticais" }, "dashboards": { "add_count_charts": "Adicionar {count} gráfico(s)", @@ -3953,7 +3961,6 @@ "allowed_values": "Valores permitidos: {values}", "api_ingestion": "Ingestão de API", "api_ingestion_settings_description": "Crie registros de feedback usando a API de Gerenciamento", - "api_ingestion_setup_description": "Use a API REST para enviar registros de feedback diretamente para o Formbricks. A documentação de ingestão da API inclui o endpoint, a estrutura do payload e detalhes de autenticação.", "auto_generated": "Gerado automaticamente", "change_file": "Alterar arquivo", "clear_mapping": "Limpar mapeamento", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index fa14d59546c3..4bb00e49a2fa 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -217,7 +217,6 @@ "code": "Código", "collapse_rows": "Recolher linhas", "column_n": "Coluna {n}", - "coming_soon": "Coming soon", "completed": "Concluído", "confirm": "Confirmar", "connect": "Conectar", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Já está no painel", "and_filter_logic": "E", "apply_changes": "Aplicar alterações", + "bar_direction": "Direção das barras", "chart": "Gráfico", "chart_added_to_dashboard": "Gráfico adicionado ao painel!", - "chart_data": "Dados do gráfico", + "chart_data": "Dados do Gráfico", "chart_data_tab": "Dados", "chart_deleted_successfully": "Gráfico eliminado com sucesso", "chart_deletion_error": "Falha ao eliminar gráfico", + "chart_display_settings": "Definições de visualização do gráfico", "chart_duplicated_successfully": "Gráfico duplicado com sucesso", "chart_duplication_error": "Falha ao duplicar gráfico", "chart_name": "Nome do gráfico", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?", "dimensions": "Dimensões", "dimensions_toggle_description": "Agrupa dados por sentimento, tipo de pergunta e outras dimensões.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Visualize e edite a configuração do seu gráfico.", "edit_chart_title": "Editar gráfico", "edit_chart_title_named": "Editar \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Agrupar por", "group_by_description": "Divide os teus dados por uma ou mais dimensões (a ordem é importante).", "group_data": "Agrupar dados", + "horizontal_bars": "Barras horizontais", "is_not_set": "não está definido", "is_set": "está definido", "language_value_unspecified": "Não especificado", @@ -1813,6 +1817,9 @@ "open_options": "Abrir opções do gráfico", "or_filter_logic": "OU", "original": "Original", + "pie_display": "Apresentar como", + "pie_display_breakdown": "Barras de discriminação", + "pie_display_pie": "Gráfico circular", "please_enter_chart_name": "Por favor, introduz um nome para o gráfico", "please_select_dashboard": "Por favor, seleciona um painel", "predefined_measures": "Medidas predefinidas", @@ -1840,7 +1847,8 @@ "start_date": "Data de início", "time_dimension": "Dimensão temporal", "time_dimension_title": "Adicionar agrupamento temporal", - "time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo." + "time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo.", + "vertical_bars": "Barras verticais" }, "dashboards": { "add_count_charts": "Adicionar {count} gráfico(s)", @@ -3953,7 +3961,6 @@ "allowed_values": "Valores permitidos: {values}", "api_ingestion": "Ingestão de API", "api_ingestion_settings_description": "Cria registos de feedback através da API de Gestão", - "api_ingestion_setup_description": "Usa a REST API para enviar registos de feedback diretamente para o Formbricks. A documentação da API de ingestão inclui o endpoint, a estrutura do payload e os detalhes de autenticação.", "auto_generated": "Gerado automaticamente", "change_file": "Alterar ficheiro", "clear_mapping": "Limpar mapeamento", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 8df8e41abd84..4707ca95617c 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -217,7 +217,6 @@ "code": "Cod", "collapse_rows": "Restrânge rânduri", "column_n": "Coloana {n}", - "coming_soon": "Coming soon", "completed": "Completat", "confirm": "Confirmare", "connect": "Conectează", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Deja pe tabloul de bord", "and_filter_logic": "ȘI", "apply_changes": "Aplică modificările", - "chart": "Grafic", + "bar_direction": "Direcția barelor", + "chart": "Diagramă", "chart_added_to_dashboard": "Grafic adăugat la tablou de bord!", - "chart_data": "Datele graficului", + "chart_data": "Date diagramă", "chart_data_tab": "Date", "chart_deleted_successfully": "Graficul a fost șters cu succes", "chart_deletion_error": "Nu s-a putut șterge graficul", + "chart_display_settings": "Setări afișare grafic", "chart_duplicated_successfully": "Graficul a fost duplicat cu succes", "chart_duplication_error": "Nu s-a putut duplica graficul", "chart_name": "Numele graficului", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?", "dimensions": "Dimensiuni", "dimensions_toggle_description": "Grupează datele după sentiment, tipul întrebării și alte dimensiuni.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Vezi și editează configurația graficului tău.", "edit_chart_title": "Editează graficul", "edit_chart_title_named": "Editează \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Grupează după", "group_by_description": "Descompune datele tale pe baza uneia sau mai multor dimensiuni (ordinea contează).", "group_data": "Grupează datele", + "horizontal_bars": "bare orizontale", "is_not_set": "nu este setat", "is_set": "este setat", "language_value_unspecified": "Nespecificat", @@ -1813,6 +1817,9 @@ "open_options": "Deschide opțiunile graficului", "or_filter_logic": "SAU", "original": "Original", + "pie_display": "Afișează ca", + "pie_display_breakdown": "Bare de detaliere", + "pie_display_pie": "Diagramă circulară", "please_enter_chart_name": "Te rugăm să introduci un nume pentru grafic", "please_select_dashboard": "Te rugăm să selectezi un tablou de bord", "predefined_measures": "Măsurători predefinite", @@ -1840,7 +1847,8 @@ "start_date": "Data de început", "time_dimension": "Dimensiune temporală", "time_dimension_title": "Adaugă grupare pe bază de timp", - "time_dimension_toggle_description": "Monitorizează tendințele în timp." + "time_dimension_toggle_description": "Monitorizează tendințele în timp.", + "vertical_bars": "bare verticale" }, "dashboards": { "add_count_charts": "Adaugă {count} grafic(e)", @@ -3953,7 +3961,6 @@ "allowed_values": "Valori permise: {values}", "api_ingestion": "Ingestie API", "api_ingestion_settings_description": "Creează înregistrări de feedback folosind Management API", - "api_ingestion_setup_description": "Folosește REST API pentru a trimite înregistrări de feedback direct în Formbricks. Documentația de ingestie API include endpoint-ul, structura payload-ului și detaliile de autentificare.", "auto_generated": "Generat automat", "change_file": "Schimbă fișierul", "clear_mapping": "Șterge maparea", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index f7da19701d19..9f0326872b6c 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -217,7 +217,6 @@ "code": "Код", "collapse_rows": "Свернуть строки", "column_n": "Столбец {n}", - "coming_soon": "Coming soon", "completed": "Завершено", "confirm": "Подтвердить", "connect": "Подключить", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Уже на дашборде", "and_filter_logic": "И", "apply_changes": "Применить изменения", + "bar_direction": "Направление столбцов", "chart": "График", "chart_added_to_dashboard": "График добавлен на панель!", "chart_data": "Данные графика", "chart_data_tab": "Данные", "chart_deleted_successfully": "График успешно удалён", "chart_deletion_error": "Не удалось удалить график", + "chart_display_settings": "Настройки отображения диаграммы", "chart_duplicated_successfully": "График успешно дублирован", "chart_duplication_error": "Не удалось дублировать график", "chart_name": "Название графика", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?", "dimensions": "Измерения", "dimensions_toggle_description": "Группируйте данные по настроению, типу вопроса и другим измерениям.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Просмотри и измени настройки своего графика.", "edit_chart_title": "Редактировать график", "edit_chart_title_named": "Редактировать «{name}»", @@ -1784,6 +1787,7 @@ "group_by": "Группировать по", "group_by_description": "Разбей свои данные по одному или нескольким измерениям (порядок имеет значение).", "group_data": "Группировать данные", + "horizontal_bars": "Горизонтальные столбцы", "is_not_set": "не задано", "is_set": "задано", "language_value_unspecified": "Не указано", @@ -1813,6 +1817,9 @@ "open_options": "Открыть настройки графика", "or_filter_logic": "ИЛИ", "original": "Оригинал", + "pie_display": "Отображать как", + "pie_display_breakdown": "Столбчатая диаграмма", + "pie_display_pie": "Круговая диаграмма", "please_enter_chart_name": "Пожалуйста, введи название графика", "please_select_dashboard": "Пожалуйста, выбери панель управления", "predefined_measures": "Предустановленные показатели", @@ -1840,7 +1847,8 @@ "start_date": "Дата начала", "time_dimension": "Временное измерение", "time_dimension_title": "Добавить группировку по времени", - "time_dimension_toggle_description": "Отслеживайте тренды с течением времени." + "time_dimension_toggle_description": "Отслеживайте тренды с течением времени.", + "vertical_bars": "Вертикальные столбцы" }, "dashboards": { "add_count_charts": "Добавить {count} график(ов)", @@ -3953,7 +3961,6 @@ "allowed_values": "Допустимые значения: {values}", "api_ingestion": "Импорт через API", "api_ingestion_settings_description": "Создавайте записи обратной связи с помощью Management API", - "api_ingestion_setup_description": "Используйте REST API для прямой отправки записей отзывов в Formbricks. Документация по API включает конечную точку, структуру данных и сведения об аутентификации.", "auto_generated": "Автоматически генерируется", "change_file": "Изменить файл", "clear_mapping": "Очистить сопоставление", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index f9afd544e9c7..0ad138677e80 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -217,7 +217,6 @@ "code": "Kod", "collapse_rows": "Dölj rader", "column_n": "Kolumn {n}", - "coming_soon": "Coming soon", "completed": "Slutförd", "confirm": "Bekräfta", "connect": "Anslut", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Redan på instrumentpanelen", "and_filter_logic": "OCH", "apply_changes": "Verkställ ändringar", + "bar_direction": "Stapelriktning", "chart": "Diagram", "chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!", "chart_data": "Diagramdata", "chart_data_tab": "Data", "chart_deleted_successfully": "Diagrammet har tagits bort", "chart_deletion_error": "Det gick inte att ta bort diagrammet", + "chart_display_settings": "Diagramvisningsinställningar", "chart_duplicated_successfully": "Diagrammet har duplicerats", "chart_duplication_error": "Det gick inte att duplicera diagrammet", "chart_name": "Diagramnamn", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?", "dimensions": "Dimensioner", "dimensions_toggle_description": "Gruppera data efter sentiment, frågetyp och andra dimensioner.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Visa och redigera din diagramkonfiguration.", "edit_chart_title": "Redigera diagram", "edit_chart_title_named": "Redigera \"{name}\"", @@ -1784,6 +1787,7 @@ "group_by": "Gruppera efter", "group_by_description": "Dela upp din data efter en eller flera dimensioner (ordningen spelar roll).", "group_data": "Gruppera data", + "horizontal_bars": "Horisontella staplar", "is_not_set": "är inte satt", "is_set": "är satt", "language_value_unspecified": "Ej angivet", @@ -1813,6 +1817,9 @@ "open_options": "Öppna diagramalternativ", "or_filter_logic": "ELLER", "original": "Original", + "pie_display": "Visa som", + "pie_display_breakdown": "Fördelningsstaplar", + "pie_display_pie": "Cirkeldiagram", "please_enter_chart_name": "Ange ett diagramnamn", "please_select_dashboard": "Välj en instrumentpanel", "predefined_measures": "Fördefinierade mått", @@ -1840,7 +1847,8 @@ "start_date": "Startdatum", "time_dimension": "Tidsdimension", "time_dimension_title": "Lägg till tidsbaserad gruppering", - "time_dimension_toggle_description": "Övervaka trender över tid." + "time_dimension_toggle_description": "Övervaka trender över tid.", + "vertical_bars": "Vertikala staplar" }, "dashboards": { "add_count_charts": "Lägg till {count} diagram", @@ -3953,7 +3961,6 @@ "allowed_values": "Tillåtna värden: {values}", "api_ingestion": "API ingestion", "api_ingestion_settings_description": "Skapa feedbackposter med hjälp av Management API", - "api_ingestion_setup_description": "Använd REST API för att skicka feedbackposter direkt till Formbricks. API-dokumentationen innehåller endpoint, datastruktur och autentiseringsdetaljer.", "auto_generated": "Automatiskt genererad", "change_file": "Byt fil", "clear_mapping": "Rensa mappning", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index b4a99380663b..5496e5c8c224 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -217,7 +217,6 @@ "code": "Kod", "collapse_rows": "Satırları daralt", "column_n": "Sütun {n}", - "coming_soon": "Coming soon", "completed": "Tamamlandı", "confirm": "Onayla", "connect": "Bağlan", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "Zaten panoda", "and_filter_logic": "VE", "apply_changes": "Değişiklikleri Uygula", + "bar_direction": "Çubuk yönü", "chart": "Grafik", "chart_added_to_dashboard": "Grafik panoya eklendi!", - "chart_data": "Grafik Verisi", + "chart_data": "Grafik Verileri", "chart_data_tab": "Veri", "chart_deleted_successfully": "Grafik başarıyla silindi", "chart_deletion_error": "Grafik silinemedi", + "chart_display_settings": "Grafik görüntüleme ayarları", "chart_duplicated_successfully": "Grafik başarıyla çoğaltıldı", "chart_duplication_error": "Grafik çoğaltılamadı", "chart_name": "Grafik Adı", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "Bu grafiği silmek istediğinden emin misin?", "dimensions": "Boyutlar", "dimensions_toggle_description": "Verileri duygu durumu, soru türü ve diğer boyutlara göre grupla.", + "distribution_segment_label": "{label}: {value} ({percent})", + "distribution_value_share": "{value} ({percent})", "edit_chart_description": "Grafik yapılandırmanı görüntüle ve düzenle.", "edit_chart_title": "Grafiği Düzenle", "edit_chart_title_named": "\"{name}\" Düzenle", @@ -1784,6 +1787,7 @@ "group_by": "Grupla", "group_by_description": "Verilerini bir veya daha fazla boyuta göre ayır (sıralama önemli).", "group_data": "Verileri grupla", + "horizontal_bars": "Yatay çubuklar", "is_not_set": "ayarlanmamış", "is_set": "ayarlanmış", "language_value_unspecified": "Belirtilmemiş", @@ -1813,6 +1817,9 @@ "open_options": "Grafik seçeneklerini aç", "or_filter_logic": "VEYA", "original": "Orijinal", + "pie_display": "Görünüm", + "pie_display_breakdown": "Dağılım çubukları", + "pie_display_pie": "Pasta grafik", "please_enter_chart_name": "Lütfen bir grafik adı gir", "please_select_dashboard": "Lütfen bir kontrol paneli seç", "predefined_measures": "Önceden Tanımlanmış Ölçümler", @@ -1840,7 +1847,8 @@ "start_date": "Başlangıç tarihi", "time_dimension": "Zaman Boyutu", "time_dimension_title": "Zaman tabanlı gruplama ekle", - "time_dimension_toggle_description": "Zaman içindeki eğilimleri izle." + "time_dimension_toggle_description": "Zaman içindeki eğilimleri izle.", + "vertical_bars": "Dikey çubuklar" }, "dashboards": { "add_count_charts": "{count} grafik ekle", @@ -3953,7 +3961,6 @@ "allowed_values": "İzin verilen değerler: {values}", "api_ingestion": "API ingestion", "api_ingestion_settings_description": "Yönetim API'sini kullanarak geri bildirim kayıtları oluştur", - "api_ingestion_setup_description": "Geri bildirim kayıtlarını doğrudan Formbricks'e göndermek için REST API'sini kullanın. API entegrasyon dokümanları, endpoint, payload yapısı ve kimlik doğrulama detaylarını içerir.", "auto_generated": "Otomatik olarak oluşturuldu", "change_file": "Dosyayı değiştir", "clear_mapping": "Eşleştirmeyi temizle", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 0a24eaef1d09..0a15b9cf7e7c 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -217,7 +217,6 @@ "code": "代码", "collapse_rows": "折叠 行", "column_n": "第 {n} 列", - "coming_soon": "Coming soon", "completed": "完成", "confirm": "确认", "connect": "连接", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "已在仪表板上", "and_filter_logic": "且", "apply_changes": "应用更改", + "bar_direction": "条形图方向", "chart": "图表", "chart_added_to_dashboard": "图表已添加到 Dashboard!", "chart_data": "图表数据", "chart_data_tab": "数据", "chart_deleted_successfully": "图表删除成功", "chart_deletion_error": "图表删除失败", + "chart_display_settings": "图表显示设置", "chart_duplicated_successfully": "图表复制成功", "chart_duplication_error": "图表复制失败", "chart_name": "图表名称", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "你确定要删除这个图表吗?", "dimensions": "维度", "dimensions_toggle_description": "按情感、问题类型和其他维度对数据进行分组。", + "distribution_segment_label": "{label}:{value}({percent})", + "distribution_value_share": "{value}({percent})", "edit_chart_description": "查看并编辑你的图表配置。", "edit_chart_title": "编辑图表", "edit_chart_title_named": "编辑“{name}”", @@ -1784,6 +1787,7 @@ "group_by": "分组依据", "group_by_description": "按一个或多个维度细分你的数据(顺序很重要)。", "group_data": "分组数据", + "horizontal_bars": "横向条形图", "is_not_set": "未设置", "is_set": "已设置", "language_value_unspecified": "未指定", @@ -1813,6 +1817,9 @@ "open_options": "打开图表选项", "or_filter_logic": "或", "original": "原始", + "pie_display": "显示为", + "pie_display_breakdown": "细分条形图", + "pie_display_pie": "饼图", "please_enter_chart_name": "请输入图表名称", "please_select_dashboard": "请选择一个 Dashboard", "predefined_measures": "预设度量", @@ -1840,7 +1847,8 @@ "start_date": "开始日期", "time_dimension": "时间维度", "time_dimension_title": "添加基于时间的分组", - "time_dimension_toggle_description": "监控随时间变化的趋势。" + "time_dimension_toggle_description": "监控随时间变化的趋势。", + "vertical_bars": "纵向条形图" }, "dashboards": { "add_count_charts": "添加 {count} 个图表", @@ -3953,7 +3961,6 @@ "allowed_values": "允许的值:{values}", "api_ingestion": "API ingestion", "api_ingestion_settings_description": "使用管理 API 创建反馈记录", - "api_ingestion_setup_description": "使用 REST API 直接将反馈记录发送到 Formbricks。API 接入文档包含端点、请求体结构和身份验证详情。", "auto_generated": "自动生成", "change_file": "更换文件", "clear_mapping": "清除映射", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 5d93866bb6de..e3d363bca2a7 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -217,7 +217,6 @@ "code": "程式碼", "collapse_rows": "摺疊列", "column_n": "欄 {n}", - "coming_soon": "Coming soon", "completed": "已完成", "confirm": "確認", "connect": "連線", @@ -1647,12 +1646,14 @@ "already_on_dashboard": "已在儀表板上", "and_filter_logic": "且", "apply_changes": "套用變更", + "bar_direction": "長條方向", "chart": "圖表", "chart_added_to_dashboard": "圖表已新增到儀表板!", "chart_data": "圖表資料", "chart_data_tab": "資料", "chart_deleted_successfully": "圖表已成功刪除", "chart_deletion_error": "刪除圖表失敗", + "chart_display_settings": "圖表顯示設定", "chart_duplicated_successfully": "圖表已成功複製", "chart_duplication_error": "圖表複製失敗", "chart_name": "圖表名稱", @@ -1694,6 +1695,8 @@ "delete_chart_confirmation": "你確定要刪除此圖表嗎?", "dimensions": "維度", "dimensions_toggle_description": "依情感、問題類型及其他維度分組資料。", + "distribution_segment_label": "{label}:{value}({percent})", + "distribution_value_share": "{value}({percent})", "edit_chart_description": "檢視並編輯你的圖表設定。", "edit_chart_title": "編輯圖表", "edit_chart_title_named": "編輯「{name}」", @@ -1784,6 +1787,7 @@ "group_by": "分組依據", "group_by_description": "依一個或多個維度細分你的資料(順序很重要)。", "group_data": "分組資料", + "horizontal_bars": "水平長條", "is_not_set": "未設定", "is_set": "已設定", "language_value_unspecified": "未指定", @@ -1813,6 +1817,9 @@ "open_options": "開啟圖表選項", "or_filter_logic": "或", "original": "原始", + "pie_display": "顯示為", + "pie_display_breakdown": "細分長條圖", + "pie_display_pie": "圓餅圖", "please_enter_chart_name": "請輸入圖表名稱", "please_select_dashboard": "請選擇一個儀表板", "predefined_measures": "預設指標", @@ -1840,7 +1847,8 @@ "start_date": "開始日期", "time_dimension": "時間維度", "time_dimension_title": "新增基於時間的分組", - "time_dimension_toggle_description": "監控隨時間變化的趨勢。" + "time_dimension_toggle_description": "監控隨時間變化的趨勢。", + "vertical_bars": "垂直長條" }, "dashboards": { "add_count_charts": "新增 {count} 個圖表", @@ -3953,7 +3961,6 @@ "allowed_values": "允許的值:{values}", "api_ingestion": "API 擷取", "api_ingestion_settings_description": "使用管理 API 建立意見回饋記錄", - "api_ingestion_setup_description": "使用 REST API 直接將意見回饋記錄傳送至 Formbricks。API 擷取文件包含端點、負載格式及驗證詳情。", "auto_generated": "自動生成", "change_file": "更換檔案", "clear_mapping": "清除對應", diff --git a/apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx b/apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx new file mode 100644 index 000000000000..0e5f2708d2e6 --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { + type TDistributionEntry, + buildDistributionSegments, + formatCellValue, + formatPercentShare, + getSemanticDimensionColor, + getSentimentMeasureColor, +} from "@/modules/ee/analysis/charts/lib/chart-utils"; +import { + getMeasureAxisLabel, + sortMeasureIdsForCategoryAxis, +} from "@/modules/ee/analysis/lib/schema-definition"; +import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; + +interface BreakdownBarsProps { + /** Rows in the dimension's display order; sections are re-sorted by share, as the pie's are. */ + sortedData: TChartDataRow[]; + dataKeys: string[]; + dataKey: string; + hasCategoryAxis: boolean; + xAxisKey: string; + formatDimensionValue: (value: unknown) => string; +} + +/** + * A pie chart's other rendering: one horizontal bar split into a section per group, sized by each + * group's share of the total, with the count and share on hover. Chosen through the pie chart's + * "Breakdown bars" display setting. + * + * The same data a pie shows, in a fraction of the height — which is what makes it worth having for + * a single distribution like sentiment, where a pie spends a lot of vertical space on six slices. + * Sections take the sentiment scale colours when the query reads sentiment, and a measure-only + * query turns each measure into a section. Ordering and palette come from + * `buildDistributionSegments`, which sorts by share exactly as `preparePieData` does, so toggling + * the display leaves every group where it was, in the colour it had. + */ +export function BreakdownBars({ + sortedData, + dataKeys, + dataKey, + hasCategoryAxis, + xAxisKey, + formatDimensionValue, +}: Readonly) { + const { t, i18n } = useTranslation(); + + let entries: TDistributionEntry[]; + if (hasCategoryAxis) { + // Grouped query: one section per row, the first measure supplying the size. + entries = sortedData.map((row, index) => ({ + key: `${String(row[xAxisKey] ?? "")}-${index}`, + label: formatDimensionValue(row[xAxisKey]), + value: row[dataKey], + color: getSemanticDimensionColor(xAxisKey, row[xAxisKey]), + })); + } else { + // Measure-only query: each measure is its own section. Sorted into the sentiment scale order + // first so that measures with an equal count still come out in a meaningful order. + entries = sortMeasureIdsForCategoryAxis(dataKeys).map((key) => ({ + key, + label: getMeasureAxisLabel(key, t), + value: sortedData.reduce((sum, row) => sum + (Number(row[key]) || 0), 0), + color: getSentimentMeasureColor(key), + })); + } + + const result = buildDistributionSegments(entries); + if (!result) { + return ( +
+ {t("workspace.analysis.charts.no_valid_data_to_display")} +
+ ); + } + + // Formatted once per section and shared by the bar and the legend. The `value (share)` and + // `label: value (share)` templates are translated, so a locale controls its own punctuation and + // the order of the two numbers. + const formattedSegments = result.segments.map((segment) => { + const value = formatCellValue(segment.value); + const percent = formatPercentShare(segment.percent, i18n.language); + return { + ...segment, + valueShare: t("workspace.analysis.charts.distribution_value_share", { value, percent }), + ariaLabel: t("workspace.analysis.charts.distribution_segment_label", { + label: segment.label, + value, + percent, + }), + }; + }); + + return ( +
+ + {/* The sections carry no text of their own: a label wide enough for the widest section is + still clipped on the narrow ones, at which point it reads as noise rather than data. + The legend below names every section instead, at a size that does not depend on how + the shares happen to fall. */} +
+ {formattedSegments.map((segment) => ( + + {/* The section is the tooltip's trigger, so it is a real button rather than a + focusable div: keyboard users reach it natively and its label is announced as + the control it is. Shrink (never grow) so the gaps come out of the sections + proportionally and the widths stay a faithful read of each share. */} + + +
+
+ {segment.label} + {segment.valueShare} +
+ + + ))} +
+ + {/* Every section named, in bar order (largest share first). */} +
    + {formattedSegments.map((segment) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx index 66324892352a..47b0c92dce53 100644 --- a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx +++ b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx @@ -1,8 +1,13 @@ "use client"; -import { type ElementType, type ReactNode } from "react"; +import { type ElementType, type ReactNode, useMemo } from "react"; import { CartesianGrid, XAxis, YAxis } from "recharts"; -import { formatXAxisTick } from "@/modules/ee/analysis/charts/lib/chart-utils"; +import { + formatCellValue, + formatXAxisTick, + getCategoryAxisWidth, + getValueLabelPadding, +} from "@/modules/ee/analysis/charts/lib/chart-utils"; import { type YAxisScale, computeYAxis } from "@/modules/ee/analysis/charts/lib/y-axis-scale"; import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; import type { ChartConfig } from "@/modules/ui/components/chart"; @@ -39,6 +44,10 @@ export interface CartesianChartProps { * boundary. Anchors the edge x-axis labels inward so they aren't clipped by the plot edge. * Leave false for band-scale charts (bars), whose edge categories are already inset. */ pointScale?: boolean; + /** Flips the chart onto its side: categories run down the y-axis and values across the x-axis. + * Bar charts only — the category labels move into a gutter on the left, sized to the labels + * present and wrapped inside it (see `getCategoryAxisWidth`). */ + horizontal?: boolean; } /** Upper bound (px) on a single x-axis label before wrapping. The per-category band clamp below @@ -141,6 +150,62 @@ function WrappingXAxisTick({ ); } +/** Category tick for a flipped (horizontal) chart. Same `foreignObject` wrapping trick as + * `WrappingXAxisTick`, but the box hangs to the left of the axis line and is centred on its + * category band, since here the labels stack down the y-axis. + * + * The box height is clamped to the band the same way `WrappingXAxisTick` clamps its width: the + * chart's height comes from its container, not from the row count, so the band shrinks as categories + * are added. A fixed three-line box overlaps its neighbours as soon as the band falls below it, so + * the label sheds lines instead — down to a single line, with the full text still on hover. */ +function WrappingYAxisTick({ + x, + y, + payload, + formatter, + axisWidth, + height, + visibleTicksCount, +}: Readonly<{ + x?: number; + y?: number; + payload?: { value?: unknown }; + formatter: (value: unknown) => string; + /** Gutter the axis reserved, so the label box matches it instead of a fixed maximum. */ + axisWidth: number; + height?: number; + visibleTicksCount?: number; +}>) { + const label = formatter(payload?.value); + const boxWidth = Math.max(1, axisWidth - X_AXIS_TICK_GAP); + + const band = height && visibleTicksCount ? height / visibleTicksCount : X_AXIS_LABEL_BOX_HEIGHT; + const boxHeight = Math.max( + X_AXIS_LABEL_LINE_HEIGHT, + Math.min(X_AXIS_LABEL_BOX_HEIGHT, band - X_AXIS_TICK_GAP) + ); + // Whole lines only — a box sized to 2.5 lines would clip the third mid-glyph rather than drop it. + const lineClamp = Math.max(1, Math.floor(boxHeight / X_AXIS_LABEL_LINE_HEIGHT)); + + return ( + +
+ + {label} + +
+
+ ); +} + export function CartesianChart({ data, xAxisKey, @@ -157,43 +222,91 @@ export function CartesianChart({ tooltipHideLabel, yAxisScale, pointScale = false, + horizontal = false, }: Readonly) { const yScale = yAxisScale ?? computeYAxis(data, dataKeys, zeroBaseline); const tickFormatter = xAxisTickFormatter ?? formatXAxisTick; + const categoryAxisWidth = useMemo(() => { + if (!horizontal || !hasCategoryAxis) return 0; + return getCategoryAxisWidth(data.map((row) => tickFormatter(row[xAxisKey]))); + }, [horizontal, hasCategoryAxis, data, xAxisKey, tickFormatter]); + + // Flipped, a bar's value label sits past its end with nothing reserving room for it, so the + // longest bar loses its label whenever the data max lands on the axis bound. The vertical axis + // solves the same problem with `padding.top`; this is that padding, sized to the widest label. + const valueLabelPadding = useMemo(() => { + if (!horizontal) return 0; + const labels = data.flatMap((row) => dataKeys.map((key) => formatCellValue(row[key]))); + return getValueLabelPadding(labels); + }, [horizontal, data, dataKeys]); return (
- + {/* syncWithTicks: draw a gridline only at each tick. Without it Recharts adds extra lines at the plot-area top/bottom edges (revealed by the YAxis padding), - which showed up as unlabelled boundary lines above 80 and below 0. */} - - - ) : ( - false - ) - } - /> - + which showed up as unlabelled boundary lines above 80 and below 0. The gridlines + always run across the value axis, which flips with the layout. */} + + {/* Flipped charts swap the axis roles: values run along the x-axis and the categories + stack down the y-axis. */} + {horizontal ? ( + + ) : ( + + ) : ( + false + ) + } + /> + )} + {horizontal ? ( + + ) : ( + false + ) + } + /> + ) : ( + + )} void; +} + +/** + * Display settings saved with the chart, so they apply wherever it renders (preview, chart + * list, dashboard widget) rather than only to the preview. Settings that the current chart + * type doesn't support are hidden instead of shown inert. + */ +export function ChartDisplaySettings({ chartType, config, onChange }: Readonly) { + const { t } = useTranslation(); + const { barOrientation, pieDisplay } = resolveChartDisplay(config); + const showBarOrientation = supportsBarOrientation(chartType); + const showPieDisplay = supportsPieDisplay(chartType); + // Generated rather than hardcoded: two of these panels on one page would otherwise share ids. + const barOrientationLabelId = useId(); + const pieDisplayLabelId = useId(); + + // For a chart type with no applicable setting the section would be a heading with nothing under it. + if (!showBarOrientation && !showPieDisplay) return null; + + return ( +
+

+ {t("workspace.analysis.charts.chart_display_settings")} +

+ +
+ {showPieDisplay && ( +
+ + , + }, + { + value: "breakdown", + label: t("workspace.analysis.charts.pie_display_breakdown"), + icon: , + }, + ]} + currentOption={pieDisplay} + handleOptionChange={(value) => onChange({ ...config, pieDisplay: value as TPieDisplay })} + /> +
+ )} + {showBarOrientation && ( +
+ + , + }, + { + value: "horizontal", + label: t("workspace.analysis.charts.horizontal_bars"), + icon: , + }, + ]} + currentOption={barOrientation} + handleOptionChange={(value) => + onChange({ ...config, barOrientation: value as TBarOrientation }) + } + /> +
+ )} +
+
+ ); +} diff --git a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx index eaa9139076e9..5b10a36f33dd 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx @@ -3,6 +3,7 @@ import { BarChart, DatabaseIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import type { TChartConfig } from "@formbricks/types/analysis"; import { ChartErrorBoundary } from "@/modules/ee/analysis/charts/components/chart-error-boundary"; import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer"; import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer"; @@ -12,6 +13,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/component interface ChartPreviewProps { chartData: AnalyticsResponse | null; + /** Display settings being edited, so the preview renders what will be saved. */ + config?: TChartConfig; isLoading?: boolean; error?: string | null; emptyMessage?: string; @@ -19,6 +22,7 @@ interface ChartPreviewProps { export function ChartPreview({ chartData, + config, isLoading = false, error, emptyMessage, @@ -87,6 +91,7 @@ export function ChartPreview({ data={data} query={chartData.query} optionLabels={chartData.optionLabels} + config={config} /> diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index 88d86126009e..a668971e4bd8 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -3,10 +3,12 @@ import { useId } from "react"; import { useTranslation } from "react-i18next"; import { Area, AreaChart, Bar, BarChart, Cell, Label, LabelList, Legend, Pie, PieChart } from "recharts"; -import type { TChartQuery } from "@formbricks/types/analysis"; +import type { TChartConfig, TChartQuery } from "@formbricks/types/analysis"; import { cn } from "@/lib/cn"; +import { BreakdownBars } from "@/modules/ee/analysis/charts/components/breakdown-bars"; import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart"; import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/polished-tooltip"; +import { resolveChartDisplay } from "@/modules/ee/analysis/charts/lib/chart-display"; import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, @@ -15,6 +17,7 @@ import { PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, formatCellValue, + formatPercentShare, formatXAxisTick, getSemanticDimensionColor, getSentimentMeasureColor, @@ -51,26 +54,29 @@ const PIE_LABEL_MIN_PERCENT = 0.02; /** Shown instead of a number when a measure had nothing to compute (an en dash, not a zero). */ const NO_DATA_PLACEHOLDER = "\u2013"; -const renderPieLabel = ({ cx, cy, midAngle, outerRadius, percent, value }: PieLabelProps) => { - if (cx == null || cy == null || midAngle == null || outerRadius == null || percent == null) return null; - if (percent < PIE_LABEL_MIN_PERCENT) return null; - const RADIAN = Math.PI / 180; - const radius = outerRadius + 22; - const x = cx + radius * Math.cos(-midAngle * RADIAN); - const y = cy + radius * Math.sin(-midAngle * RADIAN); - const textAnchor = x > cx ? "start" : "end"; - return ( - - {formatCellValue(value)} ({(percent * 100).toFixed(1)}%) - - ); -}; +// Curried with the active language: recharts calls the renderer outside React, so the locale has +// to be closed over rather than read from a hook inside it. +const createPieLabelRenderer = (locale: string) => + function PieSliceLabel({ cx, cy, midAngle, outerRadius, percent, value }: PieLabelProps) { + if (cx == null || cy == null || midAngle == null || outerRadius == null || percent == null) return null; + if (percent < PIE_LABEL_MIN_PERCENT) return null; + const RADIAN = Math.PI / 180; + const radius = outerRadius + 22; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + const textAnchor = x > cx ? "start" : "end"; + return ( + + {formatCellValue(value)} ({formatPercentShare(percent, locale)}) + + ); + }; interface PieLabelLineProps { percent?: number; @@ -136,6 +142,7 @@ interface BarChartViewProps { xAxisKey: string; chartConfig: ChartConfig; formatDimensionValue: (value: unknown) => string; + isHorizontal?: boolean; } const BarChartView = ({ @@ -146,8 +153,12 @@ const BarChartView = ({ xAxisKey, chartConfig, formatDimensionValue, + isHorizontal = false, }: Readonly) => { const { t } = useTranslation(); + // Value labels sit past the end of the bar, which is the top of a vertical bar and the + // right-hand end of a horizontal one. + const valueLabelPosition = isHorizontal ? "right" : "top"; // Measure-only queries (no dimension or time grouping) return a single row with one // column per measure. Rendered as N bar series that row forms a single category band @@ -183,11 +194,12 @@ const BarChartView = ({ tooltipCursor={false} zeroBaseline tooltipHideLabel + horizontal={isHorizontal} xAxisTickFormatter={formatMeasureLabel}> formatCellValue(value)} @@ -219,6 +231,7 @@ const BarChartView = ({ tooltipCursor={false} zeroBaseline hasCategoryAxis={hasCategoryAxis} + horizontal={isHorizontal} xAxisTickFormatter={formatDimensionValue} chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}> {dataKeys.map((key) => ( @@ -226,7 +239,7 @@ const BarChartView = ({ {!isMultiMeasure && ( formatCellValue(value)} @@ -261,7 +274,8 @@ const PieChartView = ({ chartConfig, formatDimensionValue, }: Readonly) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const renderPieLabel = createPieLabelRenderer(i18n.language); // With several measures and no dimension (e.g. the six Emotion counts), each row column is a // measure, not a slice — pivot the measures into one slice per measure so the pie shows them @@ -337,10 +351,20 @@ interface ChartRendererProps { query: TChartQuery; /** value_id → default-language label map, present when the query groups by valueId. */ optionLabels?: Record; + /** Saved display settings. Charts saved before these existed have an empty config and keep + * the previous behavior (vertical bars). */ + config?: TChartConfig; } -export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly) { +export function ChartRenderer({ + chartType, + data, + query, + optionLabels, + config, +}: Readonly) { const { t } = useTranslation(); + const { barOrientation, pieDisplay } = resolveChartDisplay(config); // Unique across charts on the same page so SVG ids don't collide. const gradientIdPrefix = useId(); @@ -419,6 +443,7 @@ export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly xAxisKey={xAxisKey} chartConfig={chartConfig} formatDimensionValue={formatDimensionValue} + isHorizontal={barOrientation === "horizontal"} /> ); case "line": @@ -491,6 +516,20 @@ export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly ); case "pie": + // A pie and a breakdown bar answer the same question — the share each group takes of the + // whole — so they are two renderings of one chart type rather than two chart types. + if (pieDisplay === "breakdown") { + return ( + + ); + } return ( + {chartData && ( + + )} +
{ diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts index 96ae79f52dbf..119755563229 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts @@ -4,6 +4,7 @@ import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState, useTransition } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import type { TChartConfig } from "@formbricks/types/analysis"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { createChartAction, @@ -12,6 +13,7 @@ import { getChartAction, updateChartAction, } from "@/modules/ee/analysis/charts/actions"; +import { sanitizeChartDisplay } from "@/modules/ee/analysis/charts/lib/chart-display"; import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions"; import type { @@ -49,6 +51,8 @@ export function useChartDialog({ const [, startTransition] = useTransition(); const [selectedChartType, setSelectedChartType] = useState(); const [chartData, setChartData] = useState(null); + // Display settings saved alongside the chart (display type, bar direction). + const [chartConfig, setChartConfig] = useState({}); const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false); const [chartName, setChartName] = useState(""); // Saved name of the chart being edited; unlike chartName it stays stable while the user types. @@ -93,6 +97,7 @@ export function useChartDialog({ lastSuggestedNameRef.current = null; setSelectedChartType(undefined); setCurrentChartId(undefined); + setChartConfig({}); setSelectedDirectoryId(directories?.[0]?.id ?? null); return; } @@ -119,6 +124,7 @@ export function useChartDialog({ setSavedChartName(chart.name); setSelectedChartType(resolveChartType(chart.type)); setCurrentChartId(chart.id); + setChartConfig(chart.config ?? {}); setSelectedDirectoryId(chart.feedbackDirectoryId); const queryResult = await executeQueryAction({ @@ -203,6 +209,7 @@ export function useChartDialog({ setIsSaving(true); let newlyCreatedChartId: string | null = null; + const configToSave = sanitizeChartDisplay(chartConfig, chartData.chartType); try { let savedChartId = currentChartId; @@ -214,7 +221,7 @@ export function useChartDialog({ name: chartName.trim(), type: chartData.chartType, query: chartData.query, - config: {}, + config: configToSave, }, }); @@ -232,7 +239,7 @@ export function useChartDialog({ name: chartName.trim(), type: chartData.chartType, query: chartData.query, - config: {}, + config: configToSave, feedbackDirectoryId: selectedDirectoryId, }, }); @@ -309,7 +316,7 @@ export function useChartDialog({ name: chartName.trim(), type: data.chartType, query: data.query, - config: {}, + config: sanitizeChartDisplay(chartConfig, data.chartType), feedbackDirectoryId: selectedDirectoryId, }, }); @@ -386,6 +393,7 @@ export function useChartDialog({ lastSuggestedNameRef.current = null; setSelectedChartType(undefined); setCurrentChartId(undefined); + setChartConfig({}); setChartLoadError(null); setSelectedDirectoryId(directories?.[0]?.id ?? null); onOpenChange(false); @@ -401,6 +409,8 @@ export function useChartDialog({ return { chartData, + chartConfig, + setChartConfig, chartName, setChartName, savedChartName, diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts new file mode 100644 index 000000000000..bcbce4acd887 --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { + resolveChartDisplay, + sanitizeChartDisplay, + supportsBarOrientation, + supportsPieDisplay, +} from "./chart-display"; + +describe("resolveChartDisplay", () => { + test("falls back to vertical bars for charts saved before these settings existed", () => { + expect(resolveChartDisplay({})).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + expect(resolveChartDisplay(undefined)).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + expect(resolveChartDisplay(null)).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + }); + + test("returns the saved setting", () => { + expect(resolveChartDisplay({ barOrientation: "horizontal" })).toEqual({ + barOrientation: "horizontal", + pieDisplay: "pie", + }); + }); +}); + +describe("supportsBarOrientation", () => { + test("only bar charts have an orientation", () => { + expect(supportsBarOrientation("bar")).toBe(true); + expect(supportsBarOrientation("area")).toBe(false); + expect(supportsBarOrientation("line")).toBe(false); + expect(supportsBarOrientation("pie")).toBe(false); + expect(supportsBarOrientation("big_number")).toBe(false); + expect(supportsBarOrientation(undefined)).toBe(false); + }); +}); + +describe("sanitizeChartDisplay", () => { + test("keeps the orientation on a bar chart", () => { + expect(sanitizeChartDisplay({ barOrientation: "horizontal" }, "bar")).toEqual({ + barOrientation: "horizontal", + }); + }); + + test("drops the orientation for chart types that cannot use it", () => { + expect(sanitizeChartDisplay({ barOrientation: "horizontal" }, "pie")).toEqual({}); + }); + + test("preserves unrelated config fields", () => { + expect(sanitizeChartDisplay({ xAxisLabel: "Question", barOrientation: "horizontal" }, "area")).toEqual({ + xAxisLabel: "Question", + }); + }); + + test("returns an empty config when nothing is set", () => { + expect(sanitizeChartDisplay(undefined, "bar")).toEqual({}); + expect(sanitizeChartDisplay({}, "bar")).toEqual({}); + }); +}); + +describe("pie display", () => { + test("falls back to the pie for charts saved before this setting existed", () => { + expect(resolveChartDisplay({}).pieDisplay).toBe("pie"); + expect(resolveChartDisplay(undefined).pieDisplay).toBe("pie"); + }); + + test("returns the saved setting", () => { + expect(resolveChartDisplay({ pieDisplay: "breakdown" }).pieDisplay).toBe("breakdown"); + }); + + test("only a pie chart supports it", () => { + expect(supportsPieDisplay("pie")).toBe(true); + expect(supportsPieDisplay("bar")).toBe(false); + expect(supportsPieDisplay(undefined)).toBe(false); + }); + + test("keeps the setting for a pie and drops it for anything else", () => { + expect(sanitizeChartDisplay({ pieDisplay: "breakdown" }, "pie")).toEqual({ + pieDisplay: "breakdown", + }); + // Switching a breakdown pie to a bar chart must not leave the setting behind to surprise + // whoever switches it back. + expect(sanitizeChartDisplay({ pieDisplay: "breakdown" }, "bar")).toEqual({}); + }); + + test("each chart type keeps only its own setting", () => { + expect(sanitizeChartDisplay({ barOrientation: "horizontal", pieDisplay: "breakdown" }, "pie")).toEqual({ + pieDisplay: "breakdown", + }); + expect(sanitizeChartDisplay({ barOrientation: "horizontal", pieDisplay: "breakdown" }, "bar")).toEqual({ + barOrientation: "horizontal", + }); + }); + + test("preserves unrelated config either way", () => { + expect(sanitizeChartDisplay({ pieDisplay: "breakdown", showLegend: true }, "pie")).toEqual({ + pieDisplay: "breakdown", + showLegend: true, + }); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-display.ts b/apps/web/modules/ee/analysis/charts/lib/chart-display.ts new file mode 100644 index 000000000000..eab23ed4b811 --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/chart-display.ts @@ -0,0 +1,43 @@ +import type { TChartConfig } from "@formbricks/types/analysis"; +import type { TChartType } from "@/modules/ee/analysis/types/analysis"; + +export type TBarOrientation = NonNullable; +export type TPieDisplay = NonNullable; + +/** Charts render with vertical bars unless the saved config says otherwise. */ +export const DEFAULT_BAR_ORIENTATION: TBarOrientation = "vertical"; +/** A pie chart renders as a pie unless the saved config says otherwise. */ +export const DEFAULT_PIE_DISPLAY: TPieDisplay = "pie"; + +/** Each setting so far belongs to exactly one chart type. */ +export const supportsBarOrientation = (chartType: TChartType | undefined): boolean => chartType === "bar"; +export const supportsPieDisplay = (chartType: TChartType | undefined): boolean => chartType === "pie"; + +/** + * Resolves the display settings a chart renders with. Charts saved before these settings + * existed have an empty config, so every field falls back to the previous behavior. + */ +export const resolveChartDisplay = ( + config: TChartConfig | null | undefined +): { barOrientation: TBarOrientation; pieDisplay: TPieDisplay } => ({ + barOrientation: config?.barOrientation ?? DEFAULT_BAR_ORIENTATION, + pieDisplay: config?.pieDisplay ?? DEFAULT_PIE_DISPLAY, +}); + +/** + * Config to persist for a chart type: settings that the type does not support are dropped + * rather than saved as dead values, so switching a bar chart to a pie chart doesn't keep a + * stale orientation around to surprise whoever switches it back. + */ +export const sanitizeChartDisplay = ( + config: TChartConfig | null | undefined, + chartType: TChartType | undefined +): TChartConfig => { + const { barOrientation, pieDisplay, ...rest } = config ?? {}; + + return { + ...rest, + ...(supportsBarOrientation(chartType) && barOrientation ? { barOrientation } : {}), + ...(supportsPieDisplay(chartType) && pieDisplay ? { pieDisplay } : {}), + }; +}; diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts index b5579ebdc7ed..1b8218a53206 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { SENTIMENT_VALUE_ORDER } from "@/modules/ee/analysis/lib/schema-definition"; import { + CATEGORY_AXIS_MAX_WIDTH, + CATEGORY_AXIS_MIN_WIDTH, CHART_BRAND_DARK, CHART_MEASURE_COLORS, CHART_NOT_ENRICHED_COLOR, @@ -9,10 +11,16 @@ import { PIE_MEASURE_VALUE_KEY, PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, + VALUE_LABEL_MAX_PADDING, + VALUE_LABEL_MIN_PADDING, + buildDistributionSegments, formatCellValue, + formatPercentShare, formatXAxisTick, + getCategoryAxisWidth, getSemanticDimensionColor, getSentimentMeasureColor, + getValueLabelPadding, pivotMeasuresToCategories, prepareMeasureSliceData, preparePieData, @@ -53,6 +61,14 @@ describe("chart-utils", () => { { [PIE_MEASURE_NAME_KEY]: "L:m.anger", [PIE_MEASURE_VALUE_KEY]: 2, tooltipLabel: "L:m.anger" }, ]); }); + + // ENG-2346: the tooltip falls back to formatting a row's dataKey when the row carries no + // `tooltipLabel`, and the slice value lives under a synthetic key that is not a Cube column — + // so a missing label surfaced to users as the literal "__measure Value". + test("carries the translated measure label so the tooltip never formats the synthetic value key", () => { + const result = prepareMeasureSliceData([{ "m.joy": 236 }], ["m.joy"], label); + expect(result[0].tooltipLabel).toBe("L:m.joy"); + }); }); describe("resolveChartType", () => { @@ -70,6 +86,66 @@ describe("chart-utils", () => { }); }); + describe("buildDistributionSegments", () => { + test("sizes each section by its share of the total", () => { + const result = buildDistributionSegments([ + { key: "a", label: "A", value: 30 }, + { key: "b", label: "B", value: 10 }, + ]); + expect(result).not.toBeNull(); + expect(result!.total).toBe(40); + expect(result!.segments.map((s) => s.percent)).toEqual([0.75, 0.25]); + }); + + test("orders sections largest first, the order preparePieData uses", () => { + const result = buildDistributionSegments([ + { key: "small", label: "Small", value: 1 }, + { key: "big", label: "Big", value: 99 }, + ]); + expect(result!.segments.map((s) => s.key)).toEqual(["big", "small"]); + }); + + test("keeps the caller's order for equal shares, so the palette handout is stable", () => { + const result = buildDistributionSegments([ + { key: "a", label: "A", value: 5 }, + { key: "b", label: "B", value: 5 }, + { key: "c", label: "C", value: 5 }, + ]); + expect(result!.segments.map((s) => s.key)).toEqual(["a", "b", "c"]); + }); + + test("drops non-positive and non-numeric entries from the total", () => { + const result = buildDistributionSegments([ + { key: "a", label: "A", value: 10 }, + { key: "zero", label: "Zero", value: 0 }, + { key: "negative", label: "Negative", value: -5 }, + { key: "text", label: "Text", value: "n/a" }, + { key: "empty", label: "Empty", value: null }, + ]); + expect(result!.total).toBe(10); + expect(result!.segments.map((s) => s.key)).toEqual(["a"]); + }); + + test("returns null when nothing positive is left to show", () => { + expect(buildDistributionSegments([])).toBeNull(); + expect(buildDistributionSegments([{ key: "a", label: "A", value: 0 }])).toBeNull(); + expect(buildDistributionSegments([{ key: "a", label: "A", value: "text" }])).toBeNull(); + }); + + test("keeps semantic colors and hands the palette only to the rest", () => { + const result = buildDistributionSegments([ + { key: "positive", label: "Positive", value: 5, color: CHART_SENTIMENT_COLORS.positive }, + { key: "other", label: "Other", value: 5 }, + { key: "another", label: "Another", value: 5 }, + ]); + expect(result!.segments.map((s) => s.color)).toEqual([ + CHART_SENTIMENT_COLORS.positive, + CHART_MEASURE_COLORS[0], + CHART_MEASURE_COLORS[1], + ]); + }); + }); + describe("preparePieData", () => { test("returns null for empty or no valid numeric data", () => { expect(preparePieData([], "count")).toBeNull(); @@ -331,3 +407,49 @@ describe("chart-utils", () => { }); }); }); + +describe("flipped bar axis sizing", () => { + test("sizes the category gutter to the labels present", () => { + // Three numeric categories used to leave ~150px of empty gutter before the bars started. + expect(getCategoryAxisWidth(["3", "10", "25"])).toBeLessThan(CATEGORY_AXIS_MAX_WIDTH / 2); + }); + + test("never drops below the floor or above the ceiling", () => { + expect(getCategoryAxisWidth(["1"])).toBe(CATEGORY_AXIS_MIN_WIDTH); + expect(getCategoryAxisWidth([])).toBe(CATEGORY_AXIS_MIN_WIDTH); + expect(getCategoryAxisWidth(["How satisfied are you with the checkout experience overall?"])).toBe( + CATEGORY_AXIS_MAX_WIDTH + ); + }); + + test("takes the longest label, not the first or last", () => { + const width = getCategoryAxisWidth(["ok", "a considerably longer label", "no"]); + expect(width).toBe(getCategoryAxisWidth(["a considerably longer label"])); + }); + + test("reserves room for the widest value label so the longest bar keeps its number", () => { + // The bug this guards: with no padding the label of a bar reaching the axis bound is anchored + // at the plot edge and clipped by the SVG viewport. + expect(getValueLabelPadding(["50", "20", "5"])).toBeGreaterThanOrEqual(VALUE_LABEL_MIN_PADDING); + expect(getValueLabelPadding(["1,234,567"])).toBeGreaterThan(getValueLabelPadding(["5"])); + }); + + test("caps the value gutter so a huge number cannot eat the plot", () => { + expect(getValueLabelPadding(["123,456,789,012,345"])).toBe(VALUE_LABEL_MAX_PADDING); + }); +}); + +describe("formatPercentShare", () => { + test("keeps one fraction digit, so a small real share is not rounded away to 0%", () => { + // 2 records out of 500: the section is drawn and hoverable, so its label must not read "0%". + expect(formatPercentShare(0.004, "en-US")).toBe("0.4%"); + expect(formatPercentShare(1 / 3, "en-US")).toBe("33.3%"); + expect(formatPercentShare(1, "en-US")).toBe("100.0%"); + }); + + test("follows the locale's decimal separator instead of a hardcoded period", () => { + const de = formatPercentShare(0.125, "de-DE"); + expect(de).toContain("12,5"); + expect(de).not.toContain("12.5"); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts index 5a8ca3193cbb..0fd8335abb9d 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts @@ -43,8 +43,7 @@ export const CHART_NOT_ENRICHED_COLOR = "#a3a3a3"; // neutral-400 * yellow; positive is the brand teal and very positive the next-darker brand step * (--color-brandnew in globals.css). Validated with the dataviz palette script on white: lightness * band and adjacent-pair CVD separation pass (worst adjacent ΔE 16.2, deutan); the dark brand teal - * sits just under the categorical chroma floor, acceptable for a brand hue. Groundwork for the - * sentiment-only chart (ENG-1558). + * sits just under the categorical chroma floor, acceptable for a brand hue. */ export const CHART_SENTIMENT_COLORS: Record = { very_negative: "#e34948", // red (palette red — sadness) @@ -81,7 +80,7 @@ export const resolveChartType = (raw: string): TChartType => { return parsed.success ? parsed.data : "bar"; }; -const isNumericValue = (val: TChartDataRow[string]): boolean => { +const isNumericValue = (val: unknown): boolean => { if (val === null || val === undefined || val === "") return false; const num = Number(val); return !Number.isNaN(num) && Number.isFinite(num); @@ -144,6 +143,82 @@ export const prepareMeasureSliceData = ( tooltipLabel: labelFor(key), })); +/** + * Format a 0-1 share as a percentage for display, in the app's active language. One fraction digit + * throughout: whole percents print a real 0.4% group as "0%" and make three equal groups add up to + * 99%, and the pie's slice labels and the breakdown bar's legend must agree to the digit, since + * they are two displays of one chart. + * + * `Intl` rather than `toFixed` so the decimal separator and the percent sign follow the locale + * ("12,5 %" in de-DE), which a hardcoded "%" suffix cannot do. + */ +export const formatPercentShare = (percent: number, locale?: string): string => + new Intl.NumberFormat(locale, { + style: "percent", + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(percent); + +/** One section of the single-bar distribution chart (a pie chart's "Breakdown bars" display). */ +export interface TDistributionSegment { + /** Stable react key: the dimension value or the measure id the segment came from. */ + key: string; + label: string; + value: number; + /** Share of the total, 0-1. */ + percent: number; + color: string; +} + +/** Input to {@link buildDistributionSegments}: one candidate section, color optional. */ +export interface TDistributionEntry { + key: string; + label: string; + value: unknown; + /** Meaning-bound color (sentiment scale, "not enriched" gray); palette color when absent. */ + color?: string; +} + +/** + * Turn labelled values into the sections of a single 100% bar: coerce to numbers, compute each + * section's share, and hand out palette colors to the entries that carry no semantic color (so a + * semantic bucket never consumes a categorical hue, as in preparePieData). + * + * Zero and negative entries are dropped: they would render as a zero-width, unhoverable section. + * Sections are ordered largest share first, the order and therefore the palette handout + * preparePieData uses, so switching a pie between its two displays doesn't move or recolour a + * group. Sorting is stable, so equal shares keep the caller's order. Returns null when nothing is + * left to show, i.e. the total is not positive. + */ +export function buildDistributionSegments( + entries: readonly TDistributionEntry[] +): { segments: TDistributionSegment[]; total: number } | null { + let paletteIndex = 0; + const scaled = entries + .map((entry) => ({ + key: entry.key, + label: entry.label, + value: isNumericValue(entry.value) ? Number(entry.value) : 0, + color: entry.color, + })) + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value); + + const total = scaled.reduce((sum, entry) => sum + entry.value, 0); + if (total <= 0) return null; + + const segments = scaled.map(({ key, label, value, color }) => { + let resolvedColor = color; + if (!resolvedColor) { + resolvedColor = CHART_MEASURE_COLORS[paletteIndex % CHART_MEASURE_COLORS.length]; + paletteIndex++; + } + return { key, label, value, percent: value / total, color: resolvedColor }; + }); + + return { segments, total }; +} + /** Category key for rows produced by {@link pivotMeasuresToCategories}. */ export const PIVOTED_MEASURE_KEY = "measure"; /** Value key for rows produced by {@link pivotMeasuresToCategories}. */ @@ -222,3 +297,47 @@ export function formatCellValue(value: unknown): string { if (typeof value === "boolean" || typeof value === "bigint") return String(value); return ""; } + +// ── Flipped (horizontal) bar chart axis sizing ──────────────────────────────── +// Both of these size a gutter to the text that will actually sit in it, rather than claiming a flat +// maximum: a flat gutter reads as a broken layout when the labels are short (three numeric +// categories left ~150px of empty space before the bars started). + +/** Approximate advance width (px) of one character at `text-xs`. Errs wide on purpose: + * over-estimating leaves a little slack, under-estimating clips or wraps text that had room. */ +const AXIS_CHAR_WIDTH = 6.5; +/** Gap (px) between a tick's text and the axis line. */ +const AXIS_TICK_GAP = 8; + +/** Ceiling (px) for the category gutter: wide enough for a short question label, capped so the bars + * keep most of the plot. Longer labels wrap inside it. */ +export const CATEGORY_AXIS_MAX_WIDTH = 160; +/** Floor (px), so a one-character label still has a readable gutter. */ +export const CATEGORY_AXIS_MIN_WIDTH = 28; + +/** Width (px) for the left-hand category gutter of a flipped bar chart, from the labels present. */ +export const getCategoryAxisWidth = (labels: string[]): number => { + const longest = labels.reduce((max, label) => Math.max(max, label.length), 0); + const needed = Math.ceil(longest * AXIS_CHAR_WIDTH) + AXIS_TICK_GAP * 2; + return Math.min(CATEGORY_AXIS_MAX_WIDTH, Math.max(CATEGORY_AXIS_MIN_WIDTH, needed)); +}; + +/** Ceiling (px) for the value-label gutter — enough for a grouped number like "1,234,567". */ +export const VALUE_LABEL_MAX_PADDING = 72; +/** Floor (px): a single digit still needs the label to clear the bar's end. */ +export const VALUE_LABEL_MIN_PADDING = 14; + +/** + * Room (px) to reserve past the end of the value axis on a flipped bar chart, so the label of the + * longest bar stays inside the SVG. + * + * A vertical chart gets this from the y-axis `padding.top`; flipped, the label moves to the right of + * the bar's end with nothing holding space for it. Whenever the data max lands exactly on the axis + * bound — which the "nice" scale produces routinely, since 10/20/50/100 are all multiples of their + * step — the label of the biggest bar, the one read first, was clipped away entirely. + */ +export const getValueLabelPadding = (labels: string[]): number => { + const longest = labels.reduce((max, label) => Math.max(max, label.length), 0); + const needed = Math.ceil(longest * AXIS_CHAR_WIDTH) + AXIS_TICK_GAP; + return Math.min(VALUE_LABEL_MAX_PADDING, Math.max(VALUE_LABEL_MIN_PADDING, needed)); +}; diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx index 06b6c8782d16..995d182d1edf 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx @@ -139,7 +139,12 @@ const MemoizedWidgetContent = memo(function WidgetContent({ if (widget.chart && dataPromise) { return ( }> - + ); } diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx index af21c28343d9..7ffc94e83f66 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx @@ -2,7 +2,7 @@ import { use } from "react"; import { useTranslation } from "react-i18next"; -import { TChartQuery } from "@formbricks/types/analysis"; +import { TChartConfig, TChartQuery } from "@formbricks/types/analysis"; import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer"; import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer"; import { DEFAULT_WIDGET_VIEW, type TWidgetView } from "@/modules/ee/analysis/dashboards/lib/widget-view"; @@ -15,12 +15,15 @@ interface DashboardWidgetDataProps { | { error: TDashboardWidgetError } >; chartType: TChartType; + /** Saved display settings of the chart behind this widget. */ + config?: TChartConfig; view?: TWidgetView; } export function DashboardWidgetData({ dataPromise, chartType, + config, view = DEFAULT_WIDGET_VIEW, }: Readonly) { const { t } = useTranslation(); @@ -49,6 +52,7 @@ export function DashboardWidgetData({ data={result.data} query={result.query} optionLabels={result.optionLabels} + config={config} /> ); } diff --git a/apps/web/modules/ui/components/options-switch/index.tsx b/apps/web/modules/ui/components/options-switch/index.tsx index cab44e95b937..cd22563bb67d 100644 --- a/apps/web/modules/ui/components/options-switch/index.tsx +++ b/apps/web/modules/ui/components/options-switch/index.tsx @@ -11,15 +11,22 @@ interface OptionsSwitchProps { options: TOption[]; currentOption: string | undefined; handleOptionChange: (value: string) => void; + /** + * Id of the element naming this group. A plain `
- {survey.responseCount} + {survey.completedResponseCount}
diff --git a/apps/web/modules/survey/list/components/survey-list.tsx b/apps/web/modules/survey/list/components/survey-list.tsx index b562520d6aa5..8067e34bb4e2 100644 --- a/apps/web/modules/survey/list/components/survey-list.tsx +++ b/apps/web/modules/survey/list/components/survey-list.tsx @@ -327,10 +327,12 @@ export const SurveysList = ({ surveyContent = (
-
+
{t("common.name")}
{t("common.status")}
-
{t("common.responses")}
+
{t("workspace.surveys.completed_responses")}
{t("common.type")}
{t("common.created_at")}
{t("common.updated_at")}
diff --git a/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts b/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts index 60529cae108e..e0c9c47406e2 100644 --- a/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts @@ -34,6 +34,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts b/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts index cff89a4931f4..1469785a5ec4 100644 --- a/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts @@ -34,6 +34,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts b/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts index b20244a5d2b8..e7deb5d4cb46 100644 --- a/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts @@ -35,6 +35,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-surveys.test.ts b/apps/web/modules/survey/list/hooks/use-surveys.test.ts index ee2e552f541d..81d9a9dd8550 100644 --- a/apps/web/modules/survey/list/hooks/use-surveys.test.ts +++ b/apps/web/modules/survey/list/hooks/use-surveys.test.ts @@ -44,6 +44,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T10:00:00.000Z", updatedAt: "2026-04-15T10:00:00.000Z", responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, @@ -70,6 +71,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T11:00:00.000Z", updatedAt: "2026-04-15T11:00:00.000Z", responseCount: 2, + completedResponseCount: 2, creator: { name: "Bob" }, singleUse: null, }, @@ -149,6 +151,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T10:00:00.000Z", updatedAt: "2026-04-15T10:00:00.000Z", responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, @@ -219,6 +222,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T11:00:00.000Z", updatedAt: "2026-04-15T11:00:00.000Z", responseCount: 4, + completedResponseCount: 4, creator: { name: "Bob" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts b/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts index cb0cf524e06f..e07fe74d565a 100644 --- a/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts +++ b/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts @@ -34,6 +34,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu publishOn: null, archivedAt: null, responseCount: 5, + completedResponseCount: 5, creator: { name: "Alice" }, singleUse: null, }, @@ -48,6 +49,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu publishOn: null, archivedAt: null, responseCount: 0, + completedResponseCount: 0, creator: { name: "Bob" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/lib/query.test.ts b/apps/web/modules/survey/list/lib/query.test.ts index 08180234f2ab..6ab06b7b09b9 100644 --- a/apps/web/modules/survey/list/lib/query.test.ts +++ b/apps/web/modules/survey/list/lib/query.test.ts @@ -14,6 +14,7 @@ const surveyA = { createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }; diff --git a/apps/web/modules/survey/list/lib/survey-page.test.ts b/apps/web/modules/survey/list/lib/survey-page.test.ts index bc0df2914cb4..e7bb3b200342 100644 --- a/apps/web/modules/survey/list/lib/survey-page.test.ts +++ b/apps/web/modules/survey/list/lib/survey-page.test.ts @@ -88,7 +88,8 @@ describe("getSurveyListPage", () => { makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_2", _count: { _all: 3 } }, + { surveyId: "survey_2", finished: true, _count: { _all: 2 } }, + { surveyId: "survey_2", finished: false, _count: { _all: 1 } }, ] as never); const page = await getSurveyListPage(workspaceId, { @@ -106,7 +107,7 @@ describe("getSurveyListPage", () => { }); expect(page.surveys).toHaveLength(1); expect(page.surveys[0].responseCount).toBe(3); - expect(page.surveys[0]).not.toHaveProperty("_count"); + expect(page.surveys[0].completedResponseCount).toBe(2); expect(page.nextCursor).not.toBeNull(); expect(decodeSurveyListPageCursor(page.nextCursor as string, "updatedAt")).toEqual({ version: 1, @@ -131,7 +132,7 @@ describe("getSurveyListPage", () => { makeSurveyRow({ id: "survey_c", name: "Charlie" }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_c", _count: { _all: 3 } }, + { surveyId: "survey_c", finished: true, _count: { _all: 3 } }, ] as never); await getSurveyListPage(workspaceId, { @@ -174,8 +175,8 @@ describe("getSurveyListPage", () => { }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_in_progress", _count: { _all: 3 } }, - { surveyId: "survey_other_1", _count: { _all: 2 } }, + { surveyId: "survey_in_progress", finished: true, _count: { _all: 3 } }, + { surveyId: "survey_other_1", finished: true, _count: { _all: 2 } }, ] as never); const page = await getSurveyListPage(workspaceId, { @@ -231,7 +232,7 @@ describe("getSurveyListPage", () => { }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_in_progress", _count: { _all: 3 } }, + { surveyId: "survey_in_progress", finished: true, _count: { _all: 3 } }, ] as never); const page = await getSurveyListPage(workspaceId, { @@ -270,7 +271,7 @@ describe("getSurveyListPage", () => { }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_other_2", _count: { _all: 3 } }, + { surveyId: "survey_other_2", finished: true, _count: { _all: 3 } }, ] as never); const page = await getSurveyListPage(workspaceId, { diff --git a/apps/web/modules/survey/list/lib/survey-record.test.ts b/apps/web/modules/survey/list/lib/survey-record.test.ts new file mode 100644 index 000000000000..c7cd8dc92cc6 --- /dev/null +++ b/apps/web/modules/survey/list/lib/survey-record.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { + type TSurveyRow, + getResponseCountsBySurveyIds, + mapSurveyRowToSurvey, + mapSurveyRowsToSurveys, +} from "./survey-record"; + +vi.mock("server-only", () => ({})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + groupBy: vi.fn(), + }, + }, +})); + +const makeSurveyRow = (overrides: Partial = {}): TSurveyRow => + ({ + id: "survey_1", + name: "Survey 1", + workspaceId: "ws_1", + type: "link", + status: "inProgress", + publishOn: null, + archivedAt: null, + createdAt: new Date("2026-04-15T10:00:00.000Z"), + updatedAt: new Date("2026-04-16T10:00:00.000Z"), + creator: { name: "Alice" }, + singleUse: null, + ...overrides, + }) as TSurveyRow; + +describe("getResponseCountsBySurveyIds", () => { + beforeEach(() => { + vi.mocked(prisma.response.groupBy).mockReset(); + }); + + test("returns an empty map without querying when there are no survey ids", async () => { + const counts = await getResponseCountsBySurveyIds([]); + + expect(counts.size).toBe(0); + expect(prisma.response.groupBy).not.toHaveBeenCalled(); + }); + + test("groups by surveyId and finished so partial responses are excluded from the completed count", async () => { + vi.mocked(prisma.response.groupBy).mockResolvedValue([ + { surveyId: "survey_1", finished: true, _count: { _all: 4 } }, + { surveyId: "survey_1", finished: false, _count: { _all: 6 } }, + { surveyId: "survey_2", finished: false, _count: { _all: 3 } }, + ] as never); + + const counts = await getResponseCountsBySurveyIds(["survey_1", "survey_2"]); + + expect(prisma.response.groupBy).toHaveBeenCalledWith({ + by: ["surveyId", "finished"], + where: { surveyId: { in: ["survey_1", "survey_2"] } }, + _count: { _all: true }, + }); + expect(counts.get("survey_1")).toEqual({ total: 10, completed: 4 }); + expect(counts.get("survey_2")).toEqual({ total: 3, completed: 0 }); + }); + + test("omits surveys without any response", async () => { + vi.mocked(prisma.response.groupBy).mockResolvedValue([] as never); + + const counts = await getResponseCountsBySurveyIds(["survey_1"]); + + expect(counts.get("survey_1")).toBeUndefined(); + }); +}); + +describe("mapSurveyRowToSurvey", () => { + test("maps both counts onto the row", () => { + const survey = mapSurveyRowToSurvey(makeSurveyRow(), { total: 9, completed: 5 }); + + expect(survey.responseCount).toBe(9); + expect(survey.completedResponseCount).toBe(5); + }); + + test("defaults both counts to zero", () => { + const survey = mapSurveyRowToSurvey(makeSurveyRow()); + + expect(survey.responseCount).toBe(0); + expect(survey.completedResponseCount).toBe(0); + }); +}); + +describe("mapSurveyRowsToSurveys", () => { + test("matches each row with its own counts and falls back to zero", () => { + const rows = [makeSurveyRow({ id: "survey_1" }), makeSurveyRow({ id: "survey_2" })]; + const countsBySurveyId = new Map([["survey_1", { total: 8, completed: 3 }]]); + + const surveys = mapSurveyRowsToSurveys(rows, countsBySurveyId); + + expect(surveys[0]).toMatchObject({ id: "survey_1", responseCount: 8, completedResponseCount: 3 }); + expect(surveys[1]).toMatchObject({ id: "survey_2", responseCount: 0, completedResponseCount: 0 }); + }); + + test("defaults to zero counts when no map is given", () => { + const surveys = mapSurveyRowsToSurveys([makeSurveyRow()]); + + expect(surveys[0].responseCount).toBe(0); + expect(surveys[0].completedResponseCount).toBe(0); + }); +}); diff --git a/apps/web/modules/survey/list/lib/survey-record.ts b/apps/web/modules/survey/list/lib/survey-record.ts index 7d88ffc82bd6..4848949d75ee 100644 --- a/apps/web/modules/survey/list/lib/survey-record.ts +++ b/apps/web/modules/survey/list/lib/survey-record.ts @@ -18,20 +18,28 @@ export const surveySelect = { archivedAt: true, singleUse: true, workspaceId: true, - _count: { - select: { responses: true }, - }, } satisfies Prisma.SurveySelect; export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>; -export async function getResponseCountsBySurveyIds(surveyIds: string[]): Promise> { +export interface TSurveyResponseCounts { + /** Every response, including partial ones. */ + total: number; + /** Responses the respondent actually finished. */ + completed: number; +} + +export async function getResponseCountsBySurveyIds( + surveyIds: string[] +): Promise> { if (surveyIds.length === 0) { return new Map(); } + // Grouping by `finished` keeps both counts in a single query: the list shows the completed + // count, while the total still gates the "this survey already has responses" edit warning. const responseCounts = await prisma.response.groupBy({ - by: ["surveyId"], + by: ["surveyId", "finished"], where: { surveyId: { in: surveyIds, @@ -42,20 +50,36 @@ export async function getResponseCountsBySurveyIds(surveyIds: string[]): Promise }, }); - return new Map(responseCounts.map(({ surveyId, _count }) => [surveyId, _count._all])); + const countsBySurveyId = new Map(); + for (const { surveyId, finished, _count } of responseCounts) { + const counts = countsBySurveyId.get(surveyId) ?? { total: 0, completed: 0 }; + counts.total += _count._all; + if (finished) { + counts.completed += _count._all; + } + countsBySurveyId.set(surveyId, counts); + } + + return countsBySurveyId; } -export function mapSurveyRowToSurvey(row: TSurveyRow, responseCount = 0): TSurvey { - const { _count: _ignored, ...rest } = row; +/** Shared so the default doesn't allocate a throwaway object per mapped row (Sonar S7737). */ +const NO_RESPONSES: Readonly = Object.freeze({ total: 0, completed: 0 }); + +export function mapSurveyRowToSurvey( + row: TSurveyRow, + responseCounts: TSurveyResponseCounts = NO_RESPONSES +): TSurvey { return { - ...rest, - responseCount, + ...row, + responseCount: responseCounts.total, + completedResponseCount: responseCounts.completed, }; } export function mapSurveyRowsToSurveys( rows: TSurveyRow[], - responseCountsBySurveyId: Map = new Map() + responseCountsBySurveyId: Map = new Map() ): TSurvey[] { - return rows.map((row) => mapSurveyRowToSurvey(row, responseCountsBySurveyId.get(row.id) ?? 0)); + return rows.map((row) => mapSurveyRowToSurvey(row, responseCountsBySurveyId.get(row.id))); } diff --git a/apps/web/modules/survey/list/lib/survey.test.ts b/apps/web/modules/survey/list/lib/survey.test.ts index 1fcf755b7db4..a2afb3677e77 100644 --- a/apps/web/modules/survey/list/lib/survey.test.ts +++ b/apps/web/modules/survey/list/lib/survey.test.ts @@ -11,19 +11,11 @@ import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils"; import { validateInputs } from "@/lib/utils/validate"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; -import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils"; +import { buildWhereClause } from "@/modules/survey/lib/utils"; import { doesWorkspaceExist, getWorkspaceWithLanguages } from "@/modules/survey/list/lib/workspace"; -import { TSurvey, TWorkspaceWithLanguages } from "../types/surveys"; +import { TWorkspaceWithLanguages } from "../types/surveys"; // Import the module to be tested -import { - copySurveyToOtherWorkspace, - getSurvey, - getSurveyCount, - getSurveys, - getSurveysSortedByRelevance, - hasArchivedSurveys, -} from "./survey"; -import { surveySelect } from "./survey-record"; +import { copySurveyToOtherWorkspace, getSurveyCount, hasArchivedSurveys } from "./survey"; vi.mock("server-only", () => ({})); @@ -48,7 +40,6 @@ vi.mock("@/lib/organization/service", () => ({ })); vi.mock("@/modules/survey/lib/utils", () => ({ - buildOrderByClause: vi.fn((sortBy) => (sortBy ? [{ [sortBy]: "desc" }] : [])), buildWhereClause: vi.fn((filterCriteria) => (filterCriteria ? { name: filterCriteria.name } : {})), })); @@ -91,9 +82,6 @@ vi.mock("@formbricks/database", () => ({ delete: vi.fn(), findFirst: vi.fn(), }, - response: { - groupBy: vi.fn(), - }, language: { // Added for language connectOrCreate in copySurvey findUnique: vi.fn(), @@ -122,7 +110,6 @@ const resetMocks = () => { vi.mocked(reactCache).mockClear(); vi.mocked(checkForInvalidMediaInBlocks).mockClear(); vi.mocked(validateInputs).mockClear(); - vi.mocked(buildOrderByClause).mockClear(); vi.mocked(buildWhereClause).mockClear(); vi.mocked(doesWorkspaceExist).mockClear(); vi.mocked(getWorkspaceWithLanguages).mockClear(); @@ -136,7 +123,6 @@ const resetMocks = () => { vi.mocked(prisma.survey.create).mockReset(); vi.mocked(prisma.segment.delete).mockReset(); vi.mocked(prisma.segment.findFirst).mockReset(); - vi.mocked(prisma.response.groupBy).mockReset(); vi.mocked(prisma.actionClass.findMany).mockReset(); vi.mocked(getQuotas).mockReset(); vi.mocked(logger.error).mockClear(); @@ -154,21 +140,6 @@ const workspaceId = "ws_1"; const surveyId = "survey_1"; const userId = "user_1"; -const mockSurveyPrisma = { - id: surveyId, - createdAt: new Date(), - updatedAt: new Date(), - name: "Test Survey", - type: "web" as any, - creator: { name: "Test User" }, - status: "draft" as any, - publishOn: null, - archivedAt: null, - singleUse: null, - workspaceId, - _count: { responses: 10 }, -}; - describe("getSurveyCount", () => { beforeEach(() => { resetMocks(); @@ -198,272 +169,6 @@ describe("getSurveyCount", () => { }); }); -describe("getSurvey", () => { - beforeEach(() => { - resetMocks(); - }); - - test("should return a survey if found", async () => { - const prismaSurvey = { ...mockSurveyPrisma }; - vi.mocked(prisma.survey.findUnique).mockResolvedValue(prismaSurvey as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId, _count: { _all: 5 } }] as any); - - const survey = await getSurvey(surveyId); - - expect(survey).toEqual({ - id: prismaSurvey.id, - createdAt: prismaSurvey.createdAt, - updatedAt: prismaSurvey.updatedAt, - name: prismaSurvey.name, - type: prismaSurvey.type, - creator: prismaSurvey.creator, - status: prismaSurvey.status, - publishOn: prismaSurvey.publishOn, - archivedAt: prismaSurvey.archivedAt, - singleUse: prismaSurvey.singleUse, - workspaceId: prismaSurvey.workspaceId, - responseCount: 5, - }); - expect(survey).not.toHaveProperty("_count"); - expect(prisma.survey.findUnique).toHaveBeenCalledWith({ - where: { id: surveyId }, - select: surveySelect, - }); - }); - - test("should return null if survey not found", async () => { - vi.mocked(prisma.survey.findUnique).mockResolvedValue(null); - const survey = await getSurvey(surveyId); - expect(survey).toBeNull(); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.findUnique).mockRejectedValue(prismaError); - await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey"); - }); - - test("should throw DatabaseError when response count lookup fails", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurveyPrisma } as any); - vi.mocked(prisma.response.groupBy).mockRejectedValue(prismaError); - - await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey"); - }); - - test("should rethrow unknown error", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.survey.findUnique).mockRejectedValue(unknownError); - await expect(getSurvey(surveyId)).rejects.toThrow(unknownError); - }); -}); - -describe("getSurveys", () => { - beforeEach(() => { - resetMocks(); - }); - - const mockPrismaSurveys = [ - { ...mockSurveyPrisma, id: "s1", name: "Survey 1" }, - { ...mockSurveyPrisma, id: "s2", name: "Survey 2" }, - ]; - const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({ - id: s.id, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - name: s.name, - type: s.type, - creator: s.creator, - status: s.status, - publishOn: s.publishOn, - archivedAt: s.archivedAt, - singleUse: s.singleUse, - workspaceId: s.workspaceId, - responseCount: s._count.responses, - })); - - test("should return surveys with default parameters", async () => { - vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s1", _count: { _all: 10 } }, - { surveyId: "s2", _count: { _all: 10 } }, - ] as any); - const surveys = await getSurveys(workspaceId); - - expect(surveys).toEqual(expectedSurveys); - expect(surveys[0]).not.toHaveProperty("_count"); - expect(prisma.survey.findMany).toHaveBeenCalledWith({ - where: { workspaceId, ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause(), - take: undefined, - skip: undefined, - }); - }); - - test("should return surveys with limit and offset", async () => { - vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurveys[0]] as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId: "s1", _count: { _all: 10 } }] as any); - const surveys = await getSurveys(workspaceId, 1, 1); - - expect(surveys).toEqual([expectedSurveys[0]]); - expect(prisma.survey.findMany).toHaveBeenCalledWith({ - where: { workspaceId, ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause(), - take: 1, - skip: 1, - }); - }); - - test("should return surveys with filterCriteria", async () => { - const filterCriteria: any = { name: "Test", sortBy: "createdAt" }; - vi.mocked(buildWhereClause).mockReturnValue({ AND: [{ name: { contains: "Test" } }] }); // Mock correct return type - vi.mocked(buildOrderByClause).mockReturnValue([{ createdAt: "desc" }]); // Mock specific return - vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s1", _count: { _all: 10 } }, - { surveyId: "s2", _count: { _all: 10 } }, - ] as any); - - const surveys = await getSurveys(workspaceId, undefined, undefined, filterCriteria); - - expect(surveys).toEqual(expectedSurveys); - expect(buildWhereClause).toHaveBeenCalledWith(filterCriteria); - expect(buildOrderByClause).toHaveBeenCalledWith("createdAt"); - expect(prisma.survey.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { workspaceId, AND: [{ name: { contains: "Test" } }] }, // Check with correct structure - orderBy: [{ createdAt: "desc" }], // Check the mocked order by - }) - ); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); - await expect(getSurveys(workspaceId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys"); - }); - - test("should rethrow unknown error", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError); - await expect(getSurveys(workspaceId)).rejects.toThrow(unknownError); - }); -}); - -describe("getSurveysSortedByRelevance", () => { - beforeEach(() => { - resetMocks(); - }); - - const mockInProgressPrisma = { - ...mockSurveyPrisma, - id: "s_inprog", - status: "inProgress" as any, - }; - const mockOtherPrisma = { - ...mockSurveyPrisma, - id: "s_other", - status: "completed" as any, - }; - - const expectedInProgressSurvey: TSurvey = { - id: mockInProgressPrisma.id, - createdAt: mockInProgressPrisma.createdAt, - updatedAt: mockInProgressPrisma.updatedAt, - name: mockInProgressPrisma.name, - type: mockInProgressPrisma.type, - creator: mockInProgressPrisma.creator, - status: mockInProgressPrisma.status, - publishOn: mockInProgressPrisma.publishOn, - archivedAt: mockInProgressPrisma.archivedAt, - singleUse: mockInProgressPrisma.singleUse, - workspaceId: mockInProgressPrisma.workspaceId, - responseCount: 3, - }; - const expectedOtherSurvey: TSurvey = { - id: mockOtherPrisma.id, - createdAt: mockOtherPrisma.createdAt, - updatedAt: mockOtherPrisma.updatedAt, - name: mockOtherPrisma.name, - type: mockOtherPrisma.type, - creator: mockOtherPrisma.creator, - status: mockOtherPrisma.status, - publishOn: mockOtherPrisma.publishOn, - archivedAt: mockOtherPrisma.archivedAt, - singleUse: mockOtherPrisma.singleUse, - workspaceId: mockOtherPrisma.workspaceId, - responseCount: 5, - }; - - test("should fetch inProgress surveys first, then others if limit not met", async () => { - vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey - vi.mocked(prisma.survey.findMany) - .mockResolvedValueOnce([mockInProgressPrisma] as any) // In-progress surveys - .mockResolvedValueOnce([mockOtherPrisma] as any); // Additional surveys - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s_inprog", _count: { _all: 3 } }, - { surveyId: "s_other", _count: { _all: 5 } }, - ] as any); - - const surveys = await getSurveysSortedByRelevance(workspaceId, 2, 0); - - expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]); - expect(surveys[0]).not.toHaveProperty("_count"); - expect(prisma.survey.count).toHaveBeenCalledWith({ - where: { workspaceId, status: "inProgress", ...buildWhereClause() }, - }); - expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, { - where: { workspaceId, status: "inProgress", ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: 2, - skip: 0, - }); - expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, { - where: { workspaceId, status: { not: "inProgress" }, ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: 1, - skip: 0, - }); - }); - - test("should only fetch inProgress surveys if limit is met", async () => { - vi.mocked(prisma.survey.count).mockResolvedValue(1); - vi.mocked(prisma.survey.findMany).mockResolvedValueOnce([mockInProgressPrisma] as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s_inprog", _count: { _all: 3 } }, - ] as any); - - const surveys = await getSurveysSortedByRelevance(workspaceId, 1, 0); - expect(surveys).toEqual([expectedInProgressSurvey]); - expect(prisma.survey.findMany).toHaveBeenCalledTimes(1); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.count).mockRejectedValue(prismaError); - await expect(getSurveysSortedByRelevance(workspaceId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys sorted by relevance"); - - resetMocks(); // Reset for the next part of the test - vi.mocked(prisma.survey.count).mockResolvedValue(0); // Make count succeed - vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); // Error on findMany - await expect(getSurveysSortedByRelevance(workspaceId)).rejects.toThrow(DatabaseError); - }); - - test("should rethrow unknown error", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.survey.count).mockRejectedValue(unknownError); - await expect(getSurveysSortedByRelevance(workspaceId)).rejects.toThrow(unknownError); - }); -}); - const mockExistingSurveyDetails = { name: "Original Survey", type: "web" as any, diff --git a/apps/web/modules/survey/list/lib/survey.ts b/apps/web/modules/survey/list/lib/survey.ts index 3b3476ff74d0..7de331d2561d 100644 --- a/apps/web/modules/survey/list/lib/survey.ts +++ b/apps/web/modules/survey/list/lib/survey.ts @@ -14,151 +14,9 @@ import { validateInputs } from "@/lib/utils/validate"; import { getTranslate } from "@/lingodotdev/server"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; -import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils"; +import { buildWhereClause } from "@/modules/survey/lib/utils"; import { doesWorkspaceExist, getWorkspaceWithLanguages } from "@/modules/survey/list/lib/workspace"; -import type { TSurvey, TWorkspaceWithLanguages } from "@/modules/survey/list/types/surveys"; -import { - type TSurveyRow, - getResponseCountsBySurveyIds, - mapSurveyRowToSurvey, - mapSurveyRowsToSurveys, - surveySelect, -} from "./survey-record"; - -export const getSurveys = reactCache( - async ( - workspaceId: string, - limit?: number, - offset?: number, - filterCriteria?: TSurveyFilterCriteria - ): Promise => { - try { - if (filterCriteria?.sortBy === "relevance") { - // Call the sortByRelevance function - return await getSurveysSortedByRelevance(workspaceId, limit, offset ?? 0, filterCriteria); - } - - // Fetch surveys normally with pagination and include response count - const surveysPrisma = await prisma.survey.findMany({ - where: { - workspaceId, - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause(filterCriteria?.sortBy), - take: limit, - skip: offset, - }); - - const responseCountsBySurveyId = await getResponseCountsBySurveyIds( - surveysPrisma.map((survey) => survey.id) - ); - - return mapSurveyRowsToSurveys(surveysPrisma, responseCountsBySurveyId); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting surveys"); - throw new DatabaseError(error.message); - } - throw error; - } - } -); - -export const getSurveysSortedByRelevance = reactCache( - async ( - workspaceId: string, - limit?: number, - offset?: number, - filterCriteria?: TSurveyFilterCriteria - ): Promise => { - try { - let surveyRows: TSurveyRow[] = []; - - const inProgressSurveyCount = await prisma.survey.count({ - where: { - workspaceId, - status: "inProgress", - ...buildWhereClause(filterCriteria), - }, - }); - - // Fetch surveys that are in progress first - const inProgressSurveys = - offset && offset > inProgressSurveyCount - ? [] - : await prisma.survey.findMany({ - where: { - workspaceId, - status: "inProgress", - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: limit, - skip: offset, - }); - - surveyRows = inProgressSurveys; - - // Determine if additional surveys are needed - if (offset !== undefined && limit && inProgressSurveys.length < limit) { - const remainingLimit = limit - inProgressSurveys.length; - const newOffset = Math.max(0, offset - inProgressSurveyCount); - const additionalSurveys = await prisma.survey.findMany({ - where: { - workspaceId, - status: { not: "inProgress" }, - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: remainingLimit, - skip: newOffset, - }); - - surveyRows = [...surveyRows, ...additionalSurveys]; - } - - const responseCountsBySurveyId = await getResponseCountsBySurveyIds( - surveyRows.map((survey) => survey.id) - ); - - return mapSurveyRowsToSurveys(surveyRows, responseCountsBySurveyId); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting surveys sorted by relevance"); - throw new DatabaseError(error.message); - } - throw error; - } - } -); - -export const getSurvey = reactCache(async (surveyId: string): Promise => { - try { - const surveyPrisma = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: surveySelect, - }); - - if (!surveyPrisma) { - return null; - } - - const responseCountsBySurveyId = await getResponseCountsBySurveyIds([surveyPrisma.id]); - - return mapSurveyRowToSurvey(surveyPrisma, responseCountsBySurveyId.get(surveyPrisma.id) ?? 0); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting survey"); - throw new DatabaseError(error.message); - } - throw error; - } -}); +import type { TWorkspaceWithLanguages } from "@/modules/survey/list/types/surveys"; const getExistingSurvey = async (surveyId: string) => { return await prisma.survey.findUnique({ diff --git a/apps/web/modules/survey/list/types/survey-overview.ts b/apps/web/modules/survey/list/types/survey-overview.ts index 9160e3fd4146..359433dd458e 100644 --- a/apps/web/modules/survey/list/types/survey-overview.ts +++ b/apps/web/modules/survey/list/types/survey-overview.ts @@ -24,6 +24,7 @@ export const ZSurveyListItem = z.object({ createdAt: z.date(), updatedAt: z.date(), responseCount: z.number(), + completedResponseCount: z.number(), creator: z .object({ name: z.string(), diff --git a/apps/web/modules/survey/list/types/surveys.ts b/apps/web/modules/survey/list/types/surveys.ts index 217fbde29ddf..068dd1b356b3 100644 --- a/apps/web/modules/survey/list/types/surveys.ts +++ b/apps/web/modules/survey/list/types/surveys.ts @@ -13,6 +13,7 @@ export const ZSurvey = z.object({ createdAt: z.date(), updatedAt: z.date(), responseCount: z.number(), + completedResponseCount: z.number(), creator: z .object({ name: z.string(), diff --git a/apps/web/playwright/survey-overview.spec.ts b/apps/web/playwright/survey-overview.spec.ts index d29c5964bde5..9cebfcdaf8f8 100644 --- a/apps/web/playwright/survey-overview.spec.ts +++ b/apps/web/playwright/survey-overview.spec.ts @@ -83,7 +83,92 @@ const createSurveySeed = async ({ }); }; +const createResponsesSeed = async ({ + surveyId, + finished, + unfinished, +}: { + surveyId: string; + finished: number; + unfinished: number; +}) => { + const rows = [ + ...Array.from({ length: finished }, () => ({ surveyId, finished: true })), + ...Array.from({ length: unfinished }, () => ({ surveyId, finished: false })), + ]; + + if (rows.length > 0) { + await prisma.response.createMany({ data: rows }); + } +}; + test.describe("Survey overview", () => { + test("counts completed responses in the list, and still warns before editing a partial-only survey", async ({ + page, + users, + }) => { + const timestamp = Date.now(); + const email = `overview-completed-${timestamp}@example.com`; + const name = `overview-completed-${timestamp}`; + const mixedSurveyName = `Mixed Survey ${timestamp}`; + const partialsSurveyName = `Partials Survey ${timestamp}`; + + const user = await users.create({ email, name, workspaceName: "Completed Counts Workspace" }); + const userId = await getUserIdForEmail(email); + + await user.login(); + await page.waitForURL(/\/workspaces\/[^/]+\/surveys/); + const workspaceId = + /\/workspaces\/([^/]+)\/surveys/.exec(page.url())?.[1] ?? + (() => { + throw new Error("Unable to determine workspace id from surveys URL"); + })(); + + const mixedSurvey = await createSurveySeed({ + workspaceId, + userId, + name: mixedSurveyName, + status: "inProgress", + }); + const partialsSurvey = await createSurveySeed({ + workspaceId, + userId, + name: partialsSurveyName, + status: "inProgress", + }); + + // 5 finished + 6 abandoned: the list must show 5, never the total of 11. + await createResponsesSeed({ surveyId: mixedSurvey.id, finished: 5, unfinished: 6 }); + // Abandoned only: the list must show 0 while the survey still *has* responses. + await createResponsesSeed({ surveyId: partialsSurvey.id, finished: 0, unfinished: 2 }); + + await page.reload(); + await expect(page.getByText(mixedSurveyName, { exact: true })).toBeVisible({ timeout: 10000 }); + + const listHeader = page.getByTestId("survey-list-header"); + await expect(listHeader.getByText("Completed", { exact: true })).toBeVisible(); + await expect(listHeader.getByText("Responses", { exact: true })).toHaveCount(0); + + const mixedRow = page.locator("div.relative.block", { + has: page.getByText(mixedSurveyName, { exact: true }), + }); + await expect(mixedRow.getByText("5", { exact: true })).toBeVisible(); + await expect(mixedRow.getByText("11", { exact: true })).toHaveCount(0); + + const partialsRow = page.locator("div.relative.block", { + has: page.getByText(partialsSurveyName, { exact: true }), + }); + await expect(partialsRow.getByText("0", { exact: true })).toBeVisible(); + + // Regression guard: the caution dialog is driven by the total, so it must still appear for a + // survey whose displayed count is 0 but which already has (partial) responses. + await partialsRow.locator("[data-testid='survey-dropdown-trigger']").click(); + // Edit is a Link, not a button; its onClick preventDefaults and opens the caution dialog instead + // of navigating, precisely because the survey has responses. + await page.getByRole("link", { name: "Edit", exact: true }).click(); + await expect(page.getByText("Edit a published survey?")).toBeVisible(); + }); + test("loads surveys, applies filters and sort, and paginates with load more", async ({ page, users }) => { const timestamp = Date.now(); const email = `overview-v3-${timestamp}@example.com`; diff --git a/docs/api-v3-reference/openapi.yml b/docs/api-v3-reference/openapi.yml index 77edef8d2649..3437cee76fa9 100644 --- a/docs/api-v3-reference/openapi.yml +++ b/docs/api-v3-reference/openapi.yml @@ -2477,6 +2477,7 @@ components: - updatedAt - archivedAt - responseCount + - completedResponseCount - creator properties: id: @@ -2519,6 +2520,10 @@ components: description: Scheduled publish time (ISO 8601), or null if not scheduled. responseCount: type: integer + description: Number of responses, including partial ones. + completedResponseCount: + type: integer + description: Number of responses the respondent finished. creator: type: - object diff --git a/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml b/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml index e4ea0911fe6e..6a3b4f325ed6 100644 --- a/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml +++ b/docs/api-v3-reference/src/components/schemas/SurveyListItem.yml @@ -18,6 +18,7 @@ required: - updatedAt - archivedAt - responseCount + - completedResponseCount - creator properties: id: @@ -61,6 +62,10 @@ properties: description: Scheduled publish time (ISO 8601), or null if not scheduled. responseCount: type: integer + description: Number of responses, including partial ones. + completedResponseCount: + type: integer + description: Number of responses the respondent finished. creator: type: - object diff --git a/packages/database/migration/20260821000000_add_response_survey_finished_index/migration.sql b/packages/database/migration/20260821000000_add_response_survey_finished_index/migration.sql new file mode 100644 index 000000000000..882cc719112d --- /dev/null +++ b/packages/database/migration/20260821000000_add_response_survey_finished_index/migration.sql @@ -0,0 +1,11 @@ +-- The survey list aggregates response counts with `GROUP BY "surveyId", "finished"`. Without +-- "finished" in an index that grouping cannot be served from the index alone, so Postgres falls back +-- to a bitmap heap scan (measured ~40x the buffer traffic of the previous surveyId-only grouping). +-- This index restores an index-only scan. +-- +-- Keep standard CREATE INDEX here: this repo applies schema migrations through Prisma migrate deploy, +-- which runs inside a transaction block and cannot execute CREATE INDEX CONCURRENTLY. Response writes +-- block for the duration of the build (SHARE lock) — the same trade as every previous Response index, +-- most recently 20250520163831_add_created_at_index_to_responses. IF NOT EXISTS keeps the migration +-- idempotent when the index already exists (e.g. created via `prisma db push`). +CREATE INDEX IF NOT EXISTS "Response_surveyId_finished_idx" ON "Response"("surveyId", "finished"); diff --git a/packages/database/schema/main.prisma b/packages/database/schema/main.prisma index 737933e477fe..cedba4a3654e 100644 --- a/packages/database/schema/main.prisma +++ b/packages/database/schema/main.prisma @@ -188,6 +188,7 @@ model Response { @@index([createdAt]) @@index([surveyId, createdAt]) // to determine monthly response count @@index([contactId, createdAt]) // to determine monthly identified users (persons) + @@index([surveyId, finished]) // survey list's (surveyId, finished) count aggregation — keeps it an index-only scan } /// Represents a label that can be applied to survey responses. From ab8fc21ff9be51cee9cf2e02d77cd7f79c553e24 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:24:03 +0000 Subject: [PATCH 6/6] chore(deps): upgrade Better Auth to 1.7.0 and migrate MCP OAuth to the resource model (#8835) --- apps/web/app/api/auth/[...all]/route.test.ts | 83 ++++- apps/web/app/api/auth/[...all]/route.ts | 24 +- apps/web/app/api/mcp/route.test.ts | 18 +- .../credential-backfill.integration.test.ts | 19 ++ apps/web/lib/account/service.test.ts | 97 ------ apps/web/lib/account/service.ts | 62 ---- apps/web/lib/utils/client-ip.test.ts | 5 +- apps/web/modules/auth/lib/auth-client.ts | 10 +- .../lib/auth-two-factor.integration.test.ts | 30 +- ...ter-auth-observability.integration.test.ts | 7 + .../lib/better-auth-observability.test.ts | 2 +- .../auth/lib/better-auth-observability.ts | 6 +- .../auth/lib/better-auth-path-label.ts | 5 +- .../lib/better-auth-redirect-uri-pin.test.ts | 162 ++++++++++ .../lib/better-auth-schema-contract.test.ts | 181 +++++++++++ .../reencode-two-factor.integration.test.ts | 17 + .../legacy-sso-callback.integration.test.ts | 77 +++++ .../auth/lib/legacy-sso-callback.test.ts | 142 +++++++++ .../modules/auth/lib/legacy-sso-callback.ts | 117 +++++++ .../auth/lib/mcp-dcr-application-type.test.ts | 143 +++++++++ .../auth/lib/mcp-dcr-application-type.ts | 96 ++++++ .../modules/auth/lib/mcp-oauth-dcr.test.ts | 174 +++++++++- .../lib/mcp-oauth-provider-options.test.ts | 48 ++- .../auth/lib/mcp-oauth-provider-options.ts | 59 +++- .../auth/lib/mcp-oauth-resource-seed.test.ts | 48 +++ apps/web/modules/auth/lib/oauth-urls.ts | 26 +- .../modules/auth/lib/signup-policy.test.ts | 2 +- .../ee/sso/components/azure-button.tsx | 8 +- .../ee/sso/components/open-id-button.tsx | 8 +- .../modules/ee/sso/components/saml-button.tsx | 8 +- .../ee/sso/lib/account-linking.test.ts | 7 + .../web/modules/ee/sso/lib/account-linking.ts | 16 +- .../ee/sso/lib/better-auth-hooks.test.ts | 14 +- .../modules/ee/sso/lib/better-auth-hooks.ts | 7 +- .../ee/sso/lib/better-auth-providers.test.ts | 156 ++++++++- .../ee/sso/lib/better-auth-providers.ts | 191 +++++++++-- apps/web/modules/ee/sso/lib/constants.ts | 19 ++ ...-run-job.render-parity.integration.test.ts | 27 +- .../runner/process-workflow-run-job.test.ts | 25 +- apps/web/modules/mcp/auth.test.ts | 52 ++- apps/web/modules/mcp/auth.ts | 29 +- apps/web/package.json | 6 +- apps/web/playwright/fixtures/users.ts | 10 + apps/web/vitestSetup.ts | 16 + docs/self-hosting/advanced/migration.mdx | 79 +++++ .../configuration/auth-sso/azure-ad-oauth.mdx | 14 +- .../configuration/auth-sso/keycloak-oidc.mdx | 13 +- .../auth-sso/open-id-connect.mdx | 14 +- packages/database/eslint.config.mjs | 12 +- .../migration.sql | 183 +++++++++++ .../migration.ts | 174 ++++++++++ packages/database/schema/main.prisma | 146 ++++++++- packages/database/src/seed.ts | 7 + pnpm-lock.yaml | 297 +++++++++++------- pnpm-workspace.yaml | 61 +--- 55 files changed, 2765 insertions(+), 494 deletions(-) delete mode 100644 apps/web/lib/account/service.test.ts delete mode 100644 apps/web/lib/account/service.ts create mode 100644 apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts create mode 100644 apps/web/modules/auth/lib/better-auth-schema-contract.test.ts create mode 100644 apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts create mode 100644 apps/web/modules/auth/lib/legacy-sso-callback.test.ts create mode 100644 apps/web/modules/auth/lib/legacy-sso-callback.ts create mode 100644 apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts create mode 100644 apps/web/modules/auth/lib/mcp-dcr-application-type.ts create mode 100644 apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts create mode 100644 packages/database/migration/20260812110000_eng_2343_better_auth_17_resource_model/migration.sql create mode 100644 packages/database/migration/20260812110001_eng_2343_backfill_oauth_resource_links/migration.ts diff --git a/apps/web/app/api/auth/[...all]/route.test.ts b/apps/web/app/api/auth/[...all]/route.test.ts index be90526c695c..424e97798563 100644 --- a/apps/web/app/api/auth/[...all]/route.test.ts +++ b/apps/web/app/api/auth/[...all]/route.test.ts @@ -12,7 +12,9 @@ import { GET, POST } from "./route"; // assert the test's own arrangement instead. `api` mirrors the shape route.ts reads the label // vocabulary from — each endpoint function carries its declared path. const { handlerMock, runWithCtxMock } = vi.hoisted(() => ({ - handlerMock: vi.fn(async () => new Response("ok", { status: 200 })), + // Parameter declared even though the body ignores it: `mock.calls` is typed from the signature, so + // without it `calls[0]` is a zero-length tuple and every `calls[0][0]` read below is a type error. + handlerMock: vi.fn(async (_request: Request) => new Response("ok", { status: 200 })), runWithCtxMock: vi.fn((fn: () => unknown) => fn()), })); @@ -24,6 +26,10 @@ vi.mock("@/modules/auth/lib/auth", () => ({ signInEmail: { path: "/sign-in/email" }, resetPassword: { path: "/reset-password" }, resetPasswordCallback: { path: "/reset-password/:token" }, + // The OAuth callback Better Auth really declares (`api/routes/callback.mjs`). Present here so the + // label vocabulary matches production: without it `callback` is not a known first segment and a + // pinned SSO callback would label `unknown` in this suite while labelling correctly in the app. + callbackOAuth: { path: "/callback/:id" }, // A nullish entry, deliberately. The label vocabulary is built at MODULE LOAD from this object, // so `endpoint.path` on a null would throw there and take down every `/api/auth/*` request — the // route, not just the tag. Importing this file at all is what asserts it does not. @@ -35,6 +41,10 @@ vi.mock("@/modules/ee/sso/lib/sso-request-context", () => ({ runWithSsoRequestContext: runWithCtxMock, })); +// NOTE on assertions below: a Request must never be asserted with `toHaveBeenCalledWith`. Request state +// lives in internal slots, so it has no own properties and ANY two Request objects compare deep-equal +// under vitest — such an assertion passes even when the handler was called with a completely different +// URL. Assert identity (`toBe`) for pass-through, and read `.url` off the recorded call for a rewrite. describe("[...all] Better Auth route (ENG-1054 cutover)", () => { beforeEach(() => { handlerMock.mockClear(); @@ -59,7 +69,7 @@ describe("[...all] Better Auth route (ENG-1054 cutover)", () => { const response = await GET(request); expect(response.status).toBe(200); expect(runWithCtxMock).toHaveBeenCalledTimes(1); - expect(handlerMock).toHaveBeenCalledWith(request); + expect(handlerMock.mock.calls[0][0]).toBe(request); expect(calls).toEqual(["wrapper:start", "handler", "wrapper:end"]); }); @@ -68,7 +78,7 @@ describe("[...all] Better Auth route (ENG-1054 cutover)", () => { const response = await POST(request); expect(response.status).toBe(200); expect(runWithCtxMock).toHaveBeenCalledTimes(1); - expect(handlerMock).toHaveBeenCalledWith(request); + expect(handlerMock.mock.calls[0][0]).toBe(request); }); test("GET and POST share the single wrapped handler", () => { @@ -114,4 +124,71 @@ describe("[...all] Better Auth route — observability context (ENG-2259)", () = expect(seen).toEqual({ path: "/reset-password/*", method: "POST" }); expect(JSON.stringify(seen)).not.toContain(token); }); + + // The label is derived from the MAPPED request, so a pinned SSO callback reports the endpoint that + // actually ran. Labelling the raw URL would file it under `/oauth2/*` — the MCP OAuth + // authorization-server facet — which is the one bucket it must never be confused with (ENG-2343). + test("labels a pinned SSO callback as the endpoint that ran, not as MCP OAuth", async () => { + const seen = await captureContextDuringHandler( + new Request("http://localhost/api/auth/oauth2/callback/openid?code=abc&state=xyz", { + method: "POST", + }) + ); + + expect(seen).toEqual({ path: "/callback/*", method: "POST" }); + }); +}); + +/** + * The pinned SSO callback URL (ENG-2343). `redirectURI` makes Better Auth advertise + * `/api/auth/oauth2/callback/{providerId}` — the URL customer IdPs have had registered since v5.2 — but no + * 1.7 route is mounted there, so this route is what serves it. The mapper itself is covered exhaustively in + * legacy-sso-callback.test.ts; what needs proving *here* is that the route actually applies it, because the + * two delegation tests above pass either way: the mapper returns the identical request object on every + * non-pinned path, so they would still be green with the call deleted. + */ +describe("[...all] Better Auth route — pinned SSO callback (ENG-2343)", () => { + beforeEach(() => { + handlerMock.mockClear(); + runWithCtxMock.mockClear(); + }); + + test("hands Better Auth the current callback path, preserving code and state", async () => { + await GET(new Request("http://localhost/api/auth/oauth2/callback/openid?code=abc&state=xyz")); + + expect(handlerMock).toHaveBeenCalledTimes(1); + const handled = handlerMock.mock.calls[0][0]; + expect(handled.url).toBe("http://localhost/api/auth/callback/openid?code=abc&state=xyz"); + }); + + test("still maps inside the SSO request context", async () => { + const calls: string[] = []; + runWithCtxMock.mockImplementationOnce(async (fn: () => unknown) => { + calls.push("wrapper:start"); + const response = await fn(); + calls.push("wrapper:end"); + return response; + }); + handlerMock.mockImplementationOnce(async () => { + calls.push("handler"); + return new Response("ok", { status: 200 }); + }); + + await GET(new Request("http://localhost/api/auth/oauth2/callback/saml?code=abc")); + + expect(calls).toEqual(["wrapper:start", "handler", "wrapper:end"]); + // Without this the test is a duplicate of the ordering test above: it would stay green with the + // mapper call deleted, since ordering does not depend on it. + expect(handlerMock.mock.calls[0][0].url).toBe("http://localhost/api/auth/callback/saml?code=abc"); + }); + + // The sibling routes of our own MCP OAuth authorization server must pass through untouched — the same + // object, not a rebuilt equivalent. + test("leaves a sibling MCP OAuth route untouched", async () => { + const request = new Request("http://localhost/api/auth/oauth2/userinfo"); + + await GET(request); + + expect(handlerMock.mock.calls[0][0]).toBe(request); + }); }); diff --git a/apps/web/app/api/auth/[...all]/route.ts b/apps/web/app/api/auth/[...all]/route.ts index dc30d561c3a1..bfb6f3a8b32f 100644 --- a/apps/web/app/api/auth/[...all]/route.ts +++ b/apps/web/app/api/auth/[...all]/route.ts @@ -1,6 +1,8 @@ import { auth } from "@/modules/auth/lib/auth"; import { createAuthPathLabeller } from "@/modules/auth/lib/better-auth-path-label"; import { runWithBetterAuthRequestContext } from "@/modules/auth/lib/better-auth-request-context"; +import { mapLegacySsoCallbackRequest } from "@/modules/auth/lib/legacy-sso-callback"; +import { normalizeDcrRequest } from "@/modules/auth/lib/mcp-dcr-application-type"; import { runWithSsoRequestContext } from "@/modules/ee/sso/lib/sso-request-context"; // Force-no-store so Better Auth's outbound SSO fetches (token exchange, userinfo, JWKS) are never @@ -25,6 +27,13 @@ const labelAuthPath = createAuthPathLabeller(Object.values(auth.api).map((endpoi * two cannot coexist: both own `/api/auth/*`). More specific `/api/auth/*` routes (the SAML bridge, * SSO-recovery completion) still take precedence over this catch-all. * + * It also serves the pinned SSO callback path via `mapLegacySsoCallbackRequest` (ENG-2343), which is + * why no separate `/api/auth/oauth2/callback/[providerId]` route exists: nothing else claims that path, + * so the catch-all already receives it. The mapping runs FIRST, and everything below reads the mapped + * request — the label especially. `/api/auth/oauth2/callback/{providerId}` is not a path Better Auth + * declares, so labelling the raw URL would bucket an SSO callback under `/oauth2/*`, which is the MCP + * OAuth authorization-server facet: the one place a reader must not confuse it with. + * * `auth.handler` is wrapped in `runWithSsoRequestContext` so the SSO database hooks can carry state * across the request via AsyncLocalStorage — the provisioning decision (`user.create.before` → * `user.create.after`) and the pending identity (`mapProfileToUser` → the collision-recovery @@ -44,9 +53,18 @@ const labelAuthPath = createAuthPathLabeller(Object.values(auth.api).map((endpoi * calls it and `return`s (`better-auth/dist/api/index.mjs:194-197`), skipping the logger path * entirely — wiring it would silence the very capture that surfaces genuine internal faults. */ -const handler = (request: Request): Promise => - runWithBetterAuthRequestContext({ path: labelAuthPath(request.url), method: request.method }, () => - runWithSsoRequestContext(() => auth.handler(request)) +const handler = async (request: Request): Promise => { + // Before anything else reads the path: this catch-all serves the pinned v5.2 SSO callback URL, which no + // Better Auth version mounts a handler on any more. Everything downstream — the endpoint label, the SSO + // hooks, the audits — reads the MAPPED request, so each sees the endpoint that actually ran. + // Two normalisations, both because 1.7 changed a contract that clients and IdPs already depend on and + // neither is ours to change: the pinned SSO callback path, and `application_type` on dynamic client + // registration (see each module). Both no-op for every other request. + const mappedRequest = await normalizeDcrRequest(mapLegacySsoCallbackRequest(request)); + return runWithBetterAuthRequestContext( + { path: labelAuthPath(mappedRequest.url), method: mappedRequest.method }, + () => runWithSsoRequestContext(() => auth.handler(mappedRequest)) ); +}; export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index 7125ed642a73..23b8bcd19631 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -18,15 +18,15 @@ import { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { POST } from "./route"; -const { verifyAccessTokenMock, userFindUniqueMock } = vi.hoisted(() => ({ - verifyAccessTokenMock: vi.fn(), +const { verifyBearerTokenMock, userFindUniqueMock } = vi.hoisted(() => ({ + verifyBearerTokenMock: vi.fn(), userFindUniqueMock: vi.fn(), })); vi.mock("@better-auth/oauth-provider/resource-client", () => ({ oauthProviderResourceClient: vi.fn(() => ({ getActions: () => ({ - verifyAccessToken: verifyAccessTokenMock, + verifyBearerToken: verifyBearerTokenMock, }), })), })); @@ -147,7 +147,7 @@ describe("POST /api/mcp", () => { vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true }); vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); userFindUniqueMock.mockResolvedValue({ isActive: true }); - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", email: "person@example.com", @@ -415,7 +415,7 @@ describe("POST /api/mcp", () => { expect(response.status).toBe(200); await readMcpResponse(response); expect(authenticateApiKeyFromHeaders).toHaveBeenCalledTimes(1); - expect(verifyAccessTokenMock).not.toHaveBeenCalled(); + expect(verifyBearerTokenMock).not.toHaveBeenCalled(); expect(listV3Surveys).toHaveBeenCalledWith( expect.objectContaining({ authentication: apiKeyAuth, @@ -450,7 +450,7 @@ describe("POST /api/mcp", () => { expect(response.status).toBe(200); expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); - expect(verifyAccessTokenMock).toHaveBeenCalledWith( + expect(verifyBearerTokenMock).toHaveBeenCalledWith( "eyJhbGciOiJFZERTQSJ9.payload.signature", expect.objectContaining({ verifyOptions: expect.objectContaining({ @@ -478,7 +478,7 @@ describe("POST /api/mcp", () => { }); test("rejects invalid OAuth bearer tokens with an OAuth challenge", async () => { - verifyAccessTokenMock.mockRejectedValueOnce(new Error("invalid token")); + verifyBearerTokenMock.mockRejectedValueOnce(new Error("invalid token")); const response = await POST( createMcpRequest( @@ -503,7 +503,7 @@ describe("POST /api/mcp", () => { }); test("blocks write tools for read-only OAuth tokens", async () => { - verifyAccessTokenMock.mockResolvedValueOnce({ + verifyBearerTokenMock.mockResolvedValueOnce({ aud: MCP_AUDIENCE, sub: "user_1", email: "person@example.com", @@ -549,7 +549,7 @@ describe("POST /api/mcp", () => { test("blocks workflow write tools for tokens without workflows:write", async () => { // A write-capable user whose OAuth token was only granted read scopes (surveys:read + workflows:read) // must not be able to reach a workflow mutation — the ENG-1967 token-scope boundary. - verifyAccessTokenMock.mockResolvedValueOnce({ + verifyBearerTokenMock.mockResolvedValueOnce({ aud: MCP_AUDIENCE, sub: "user_1", email: "person@example.com", diff --git a/apps/web/integration/credential-backfill.integration.test.ts b/apps/web/integration/credential-backfill.integration.test.ts index 1b48bb7778a8..e7370755ce0a 100644 --- a/apps/web/integration/credential-backfill.integration.test.ts +++ b/apps/web/integration/credential-backfill.integration.test.ts @@ -1,3 +1,4 @@ +import { createLocalAccountIssuer } from "@better-auth/core/db"; import { beforeEach, describe, expect, test } from "vitest"; import { prisma } from "@formbricks/database"; import { resetDb } from "@/integration/reset-db"; @@ -6,6 +7,20 @@ import { auth } from "@/modules/auth/lib/auth"; // The cutover data migration under test (auto-discovered by the migration runner at the flip). import { backfillCredentialAccounts } from "../../../packages/database/migration/20260619120000_eng_1054_credential_account_backfill/migration"; +/** + * This migration predates `Account.issuer` (ENG-2343) and, by the runner's own interleaving guarantee + * (data and schema migrations run in strict timestamp order), always runs BEFORE the schema migration + * that adds that column — so it genuinely cannot set it, and its rows are inserted with issuer=NULL. + * In real deployments that's fine: ENG-2343's schema migration runs immediately after this one and + * backfills every NULL-issuer credential row. A test calling this function standalone has to simulate + * that follow-up step itself before asserting a real Better Auth sign-in succeeds. + */ +const applyEng2343IssuerBackfill = (): Promise<{ count: number }> => + prisma.account.updateMany({ + where: { provider: "credential", issuer: null }, + data: { issuer: createLocalAccountIssuer("credential") }, + }); + /** * Integration coverage for the cutover credential-account backfill (ENG-1054) against real Postgres. * Proves the scariest cutover guarantee: an existing NextAuth-era user (bcrypt hash on User.password, @@ -39,6 +54,8 @@ describe("Credential-account backfill (real Postgres)", () => { expect(account?.userId).toBe(user.id); expect(account?.password).toBe(user.password); + await applyEng2343IssuerBackfill(); + // and BA email/password sign-in works with the ORIGINAL password const res = await auth.api.signInEmail({ body: { email: "legacy@example.com", password }, @@ -124,6 +141,8 @@ describe("Credential-account backfill (real Postgres)", () => { const stats = await backfillCredentialAccounts(prisma); expect(stats.inserted).toBe(1); + await applyEng2343IssuerBackfill(); + // both the SSO and the new credential account coexist, and password sign-in works expect(await prisma.account.count({ where: { userId: user.id } })).toBe(2); const res = await auth.api.signInEmail({ diff --git a/apps/web/lib/account/service.test.ts b/apps/web/lib/account/service.test.ts deleted file mode 100644 index ab7ed9062df8..000000000000 --- a/apps/web/lib/account/service.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { Prisma } from "@formbricks/database/prisma"; -import { upsertAccount } from "./service"; - -const { mockUpsert } = vi.hoisted(() => ({ - mockUpsert: vi.fn(), -})); - -vi.mock("@formbricks/database", () => ({ - prisma: { - account: { - upsert: mockUpsert, - }, - }, -})); - -describe("account service", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test("upsertAccount keeps user ownership immutable on update", async () => { - const accountData = { - userId: "user-1", - type: "oauth", - provider: "google", - providerAccountId: "provider-1", - access_token: "access-token", - refresh_token: "refresh-token", - expires_at: 123, - scope: "openid email", - token_type: "Bearer", - id_token: "id-token", - }; - - mockUpsert.mockResolvedValue({ - id: "account-1", - createdAt: new Date(), - updatedAt: new Date(), - ...accountData, - }); - - await upsertAccount(accountData); - - expect(mockUpsert).toHaveBeenCalledWith({ - where: { - provider_providerAccountId: { - provider: "google", - providerAccountId: "provider-1", - }, - }, - create: accountData, - update: { - access_token: "access-token", - refresh_token: "refresh-token", - expires_at: 123, - scope: "openid email", - token_type: "Bearer", - id_token: "id-token", - }, - }); - }); - - test("upsertAccount wraps Prisma known request errors", async () => { - const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), { - message: "duplicate account", - }); - - mockUpsert.mockRejectedValue(prismaError); - - await expect( - upsertAccount({ - userId: "user-1", - type: "oauth", - provider: "google", - providerAccountId: "provider-1", - }) - ).rejects.toMatchObject({ - name: "DatabaseError", - message: "duplicate account", - }); - }); - - test("upsertAccount rethrows non-Prisma errors", async () => { - const error = new Error("unexpected failure"); - mockUpsert.mockRejectedValue(error); - - await expect( - upsertAccount({ - userId: "user-1", - type: "oauth", - provider: "google", - providerAccountId: "provider-1", - }) - ).rejects.toThrow("unexpected failure"); - }); -}); diff --git a/apps/web/lib/account/service.ts b/apps/web/lib/account/service.ts deleted file mode 100644 index c8e070b80e26..000000000000 --- a/apps/web/lib/account/service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { prisma } from "@formbricks/database"; -import { Prisma, PrismaClient } from "@formbricks/database/prisma"; -import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account"; -import { DatabaseError } from "@formbricks/types/errors"; -import { validateInputs } from "../utils/validate"; - -type TAccountDbClient = PrismaClient | Prisma.TransactionClient; - -const getDbClient = (tx?: Prisma.TransactionClient): TAccountDbClient => tx ?? prisma; - -export const createAccount = async (accountData: TAccountInput): Promise => { - validateInputs([accountData, ZAccountInput]); - - try { - const account = await prisma.account.create({ - data: accountData, - }); - return account; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; - -export const upsertAccount = async ( - accountData: TAccountInput, - tx?: Prisma.TransactionClient -): Promise => { - const [validatedAccountData] = validateInputs([accountData, ZAccountInput]); - const updateAccountData: Omit = { - access_token: validatedAccountData.access_token, - refresh_token: validatedAccountData.refresh_token, - expires_at: validatedAccountData.expires_at, - scope: validatedAccountData.scope, - token_type: validatedAccountData.token_type, - id_token: validatedAccountData.id_token, - }; - - try { - const account = await getDbClient(tx).account.upsert({ - where: { - provider_providerAccountId: { - provider: validatedAccountData.provider, - providerAccountId: validatedAccountData.providerAccountId, - }, - }, - create: validatedAccountData, - update: updateAccountData, - }); - - return account; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; diff --git a/apps/web/lib/utils/client-ip.test.ts b/apps/web/lib/utils/client-ip.test.ts index b2b4b6123d91..3dab8d914a8b 100644 --- a/apps/web/lib/utils/client-ip.test.ts +++ b/apps/web/lib/utils/client-ip.test.ts @@ -1,4 +1,5 @@ -import { getIp } from "@better-auth/core/utils/ip"; +// Renamed getIp -> getIP in Better Auth 1.7 (ENG-2343). +import { getIP } from "@better-auth/core/utils/ip"; import * as nextHeaders from "next/headers"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -159,7 +160,7 @@ describe("Better Auth IP configuration", () => { ipAddressHeaders: [FORMBRICKS_CLIENT_IP_HEADER], ipv6Subnet: 64, }); - expect(getIp(requestHeaders, { advanced: { ipAddress: BETTER_AUTH_IP_ADDRESS_CONFIG } } as never)).toBe( + expect(getIP(requestHeaders, { advanced: { ipAddress: BETTER_AUTH_IP_ADDRESS_CONFIG } } as never)).toBe( "2001:0db8:abcd:0012:0000:0000:0000:0000" ); }); diff --git a/apps/web/modules/auth/lib/auth-client.ts b/apps/web/modules/auth/lib/auth-client.ts index 24a863a95e11..7f5fcf425608 100644 --- a/apps/web/modules/auth/lib/auth-client.ts +++ b/apps/web/modules/auth/lib/auth-client.ts @@ -1,14 +1,18 @@ import { oauthProviderClient } from "@better-auth/oauth-provider/client"; -import { genericOAuthClient, twoFactorClient } from "better-auth/client/plugins"; +import { twoFactorClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; /** * Better Auth client (ENG-1054). Same-origin, so no baseURL is needed. This is the ONLY * auth module a `"use client"` component should import. Client plugins must mirror the server - * plugins in auth.ts (genericOAuth providers are added in Phase 5). + * plugins in auth.ts. + * + * genericOAuth has no client plugin from Better Auth 1.7 (ENG-2343): it was rebuilt onto the + * built-in social provider path, so `signIn.social({ provider })` drives Azure/OIDC/SAML too and + * `signIn.oauth2` no longer exists. */ export const authClient = createAuthClient({ - plugins: [twoFactorClient(), genericOAuthClient(), oauthProviderClient()], + plugins: [twoFactorClient(), oauthProviderClient()], }); export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts b/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts index 47012fc4cdf5..5ece6e5b841c 100644 --- a/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts +++ b/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts @@ -37,15 +37,32 @@ beforeEach(async () => { await resetDb(); }); +/** + * Better Auth 1.7 types `enableTwoFactor`'s response as a union on `method` — `{ method: "otp" }` carries + * no `totpURI` at all — so destructuring it directly no longer typechecks (surfaced once test files + * entered the typecheck graph, #8890). + * + * Narrowed rather than cast, deliberately: our config enrols TOTP, and if a future version ever answers + * `otp` here this fails with a legible message instead of feeding `undefined` into `secretFromUri` and + * failing several lines later as an unreadable TOTP error. + */ +const enrolTotp = async (cookie: string): Promise => { + const enrolled = await auth.api.enableTwoFactor({ + body: { password: "Passw0rd!" }, + headers: { cookie }, + }); + if (enrolled.method !== "totp") { + throw new Error(`expected a TOTP enrolment, got method="${enrolled.method}"`); + } + return enrolled.totpURI; +}; + describe("Better Auth two-factor (real Postgres)", () => { test("enabling 2FA + verifying a TOTP flips twoFactorEnabled and stores the secret", async () => { const userId = await createVerifiedUser("tfa@example.com", "Passw0rd!"); const cookie = await sessionCookie("tfa@example.com", "Passw0rd!"); - const { totpURI } = await auth.api.enableTwoFactor({ - body: { password: "Passw0rd!" }, - headers: { cookie }, - }); + const totpURI = await enrolTotp(cookie); expect(totpURI).toContain("otpauth://"); await auth.api.verifyTOTP({ body: { code: totp(secretFromUri(totpURI)) }, headers: { cookie } }); @@ -58,10 +75,7 @@ describe("Better Auth two-factor (real Postgres)", () => { test("an enabled second factor gates sign-in: password yields a challenge, TOTP issues the session", async () => { await createVerifiedUser("login2fa@example.com", "Passw0rd!"); const enrollCookie = await sessionCookie("login2fa@example.com", "Passw0rd!"); - const { totpURI } = await auth.api.enableTwoFactor({ - body: { password: "Passw0rd!" }, - headers: { cookie: enrollCookie }, - }); + const totpURI = await enrolTotp(enrollCookie); const secret = secretFromUri(totpURI); await auth.api.verifyTOTP({ body: { code: totp(secret) }, headers: { cookie: enrollCookie } }); await prisma.session.deleteMany(); // clear the enrollment session diff --git a/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts b/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts index 7c76baffcc6f..3a8f52d36855 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts @@ -1,3 +1,4 @@ +import { createLocalAccountIssuer } from "@better-auth/core/db"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { resetDb } from "@/integration/reset-db"; @@ -46,6 +47,9 @@ describe("Observability — signedIn audit on session creation (real Postgres)", provider: "credential", providerAccountId: user.id, password: user.password!, + // Represents an already-migrated existing account (ENG-2343); sign-in's findCredentialAccount + // filters on this. + issuer: createLocalAccountIssuer("credential"), }, }); @@ -85,6 +89,9 @@ describe("Observability — failed-login audit on a rejected sign-in (real Postg provider: "credential", providerAccountId: user.id, password: user.password!, + // Represents an already-migrated existing account (ENG-2343); sign-in's findCredentialAccount + // filters on this. + issuer: createLocalAccountIssuer("credential"), }, }); diff --git a/apps/web/modules/auth/lib/better-auth-observability.test.ts b/apps/web/modules/auth/lib/better-auth-observability.test.ts index f900a28101e1..61ee954fdfb9 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.test.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.test.ts @@ -89,7 +89,7 @@ describe("getSignInAuthMethod (signedIn audit allow-list)", () => { ["/two-factor/verify-totp", "password"], ["/two-factor/verify-backup-code", "password"], ["/callback/google", "sso"], - ["/oauth2/callback/azuread", "sso"], + ["/callback/azuread", "sso"], ])("audits sign-in completion %s as %s", (path, expected) => { expect(getSignInAuthMethod(path)).toBe(expected); }); diff --git a/apps/web/modules/auth/lib/better-auth-observability.ts b/apps/web/modules/auth/lib/better-auth-observability.ts index cc5c5d2ce8fd..c7eee7c35143 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.ts @@ -35,7 +35,11 @@ import { logAuthAttempt, shouldLogAuthFailure } from "./utils"; */ export const getSignInAuthMethod = (path: string | undefined): string | null => { if (!path) return null; - // /callback/:id (social) and /oauth2/callback/:providerId (generic OAuth/SAML) both contain /callback/ + // /callback/:id covers both built-in social and, since Better Auth 1.7 (ENG-2343), genericOAuth/SAML + // too — they share the social-provider route now instead of the old /oauth2/callback/:providerId. + // This is the INTERNAL endpoint path, which is why it is not the pinned public callback URL: the URL + // customers register stays /api/auth/oauth2/callback/{providerId}, and legacy-sso-callback.ts maps it + // onto this route before Better Auth sees it. Do not "restore" /oauth2/ here. if (path.includes("/callback/")) return "sso"; if (path === "/sign-in/email") return "password"; // Auto-login after email verification (autoSignInAfterVerification, ENG-1746) creates a session for diff --git a/apps/web/modules/auth/lib/better-auth-path-label.ts b/apps/web/modules/auth/lib/better-auth-path-label.ts index 2169c160b92d..f0a9701667bd 100644 --- a/apps/web/modules/auth/lib/better-auth-path-label.ts +++ b/apps/web/modules/auth/lib/better-auth-path-label.ts @@ -32,8 +32,9 @@ import "server-only"; // Deliberately a local copy of the literal in oauth-urls.ts rather than an import: that module reads // `@/lib/env`, and pulling env validation into this one would cost it the property that makes it -// exhaustively testable — no dependencies, no environment. Both sites are grep-findable as -// "/api/auth" if the base path ever becomes configurable (ENG-606). +// exhaustively testable — no dependencies, no environment. All three sites — this one, oauth-urls.ts and +// legacy-sso-callback.ts — are grep-findable as "/api/auth" if the base path ever becomes +// configurable (ENG-606). const AUTH_BASE_PATH = "/api/auth"; /** Emitted when the URL is unparseable or names no endpoint we serve. Bounds tag cardinality. */ diff --git a/apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts b/apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts new file mode 100644 index 000000000000..7a1d75212643 --- /dev/null +++ b/apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts @@ -0,0 +1,162 @@ +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { genericOAuth } from "better-auth/plugins"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +/** + * The upgrade guard for the pinned SSO callback URL (ENG-2343). + * + * Our three generic-OAuth providers set `redirectURI` so the callback URL stops tracking Better Auth's + * routing — it has already moved twice (1.6's genericOAuth plugin mounted `/oauth2/callback/:providerId`; + * 1.7 rebuilt the plugin onto the built-in `/callback/:id`), and each move otherwise forces every + * self-hoster to re-register a redirect URI at their IdP, which OAuth requires to match exactly. + * + * That pin rests on an upstream option we do not control. Both the authorization request and the token + * exchange resolve it as `options.redirectURI || redirectURI`, so if a future release drops or reorders + * that precedence, Better Auth silently starts advertising its own default path again and every SSO + * sign-in fails at the IdP with a redirect-URI mismatch — in production, on upgrade, with nothing in our + * own diff to explain it. This asserts the behaviour against a REAL Better Auth instance so the failure + * lands here instead. + * + * Deliberately not a unit test of our config (better-auth-providers.test.ts covers that) and deliberately + * network-free: the provider is configured with explicit endpoint URLs rather than `discoveryUrl`, which + * is the same shape the SAML bridge provider uses in production. + */ + +const BASE_URL = "https://app.formbricks.test"; +const PINNED_REDIRECT_URI = `${BASE_URL}/api/auth/oauth2/callback/pinned-provider`; +const IDP = "https://idp.formbricks.test"; + +const createAuthInstance = () => + betterAuth({ + baseURL: BASE_URL, + secret: "better-auth-redirect-uri-pin-test-secret", + // memoryAdapter does not create models lazily, so every model the sign-in touches is declared here. + database: memoryAdapter({ user: [], session: [], account: [], verification: [] }), + plugins: [ + genericOAuth({ + config: [ + { + providerId: "pinned-provider", + clientId: "pinned-client", + clientSecret: "pinned-secret", + authorizationUrl: `${IDP}/authorize`, + tokenUrl: `${IDP}/token`, + userInfoUrl: `${IDP}/userinfo`, + scopes: ["openid", "email", "profile"], + pkce: true, + redirectURI: PINNED_REDIRECT_URI, + }, + ], + }), + ], + }); + +const getAuthorizationUrl = async (): Promise => { + const auth = createAuthInstance(); + // `signInSocial`, not a genericOAuth endpoint: in 1.7 the plugin registers no routes of its own, it + // only appends its providers into `ctx.socialProviders`. That is the whole reason the callback path + // moved, so driving the core endpoint is what exercises the real production path. + const response = await auth.api.signInSocial({ + body: { provider: "pinned-provider", callbackURL: "/" }, + }); + return new URL((response as { url: string }).url); +}; + +describe("Better Auth honours the pinned SSO redirect URI", () => { + test("sends our redirectURI to the IdP rather than its own callback path", async () => { + const authorizationUrl = await getAuthorizationUrl(); + + expect(authorizationUrl.origin + authorizationUrl.pathname).toBe(`${IDP}/authorize`); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(PINNED_REDIRECT_URI); + }); + + /** + * The specific regression to catch. Better Auth's own default is `/api/auth/callback/{providerId}`, + * which is what a dropped `redirectURI` would fall back to — asserting the absence of that string is + * what distinguishes "the option was honoured" from "the option happened to match the default". + */ + test("never falls back to the version default callback path", async () => { + const authorizationUrl = await getAuthorizationUrl(); + const redirectUri = authorizationUrl.searchParams.get("redirect_uri") ?? ""; + + expect(redirectUri).toContain("/api/auth/oauth2/callback/"); + expect(redirectUri).not.toBe(`${BASE_URL}/api/auth/callback/pinned-provider`); + }); +}); + +/** + * The other half of the pin, and the one that fails in production if it regresses. + * + * `options.redirectURI || redirectURI` is resolved TWICE by upstream — once building the authorization + * URL (`create-authorization-url.mjs`) and once building the token request + * (`validate-authorization-code.mjs`). The tests above only drive the first. If a release kept the option + * on the authorization leg and dropped it on the token leg, they would all stay green while every SSO + * sign-in died at the identity provider's token endpoint with a `redirect_uri` mismatch — the two legs + * MUST send the same value, and that is what this asserts. + * + * Driven as a real two-leg flow against one instance so the `state` verification row and its signed + * cookie are the genuine ones: sign-in through `auth.handler` to get the state + cookie, then the + * callback with both, with `fetch` stubbed at the IdP boundary to capture what was posted. + */ +describe("Better Auth sends the pinned redirect URI on the token leg too", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("the token request carries the same redirect_uri as the authorization request", async () => { + const auth = createAuthInstance(); + + const signIn = await auth.handler( + new Request(`${BASE_URL}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE_URL }, + body: JSON.stringify({ provider: "pinned-provider", callbackURL: "/" }), + }) + ); + expect(signIn.status).toBe(200); + + const { url: authorizationUrl } = (await signIn.json()) as { url: string }; + const authorizationRedirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri"); + const state = new URL(authorizationUrl).searchParams.get("state") ?? ""; + expect(state).not.toBe(""); + + // The signed state cookie Better Auth just issued; the callback rejects the state without it. + const cookie = (signIn.headers.getSetCookie?.() ?? []).map((value) => value.split(";")[0]).join("; "); + expect(cookie).not.toBe(""); + + let tokenRedirectUri: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (requested.startsWith(`${IDP}/token`)) { + // `redirect_uri` is form-encoded in the token request body — the value under test. + tokenRedirectUri = new URLSearchParams(String(init?.body ?? "")).get("redirect_uri"); + return Response.json({ + access_token: "pinned-access-token", + token_type: "Bearer", + expires_in: 3600, + scope: "openid email profile", + }); + } + if (requested.startsWith(`${IDP}/userinfo`)) { + return Response.json({ + sub: "pinned-subject", + email: "pinned@formbricks.test", + email_verified: true, + name: "Pinned Person", + }); + } + throw new Error(`unexpected outbound fetch: ${requested}`); + }); + + await auth.handler( + new Request(`${BASE_URL}/api/auth/callback/pinned-provider?code=pinned-code&state=${state}`, { + headers: { cookie }, + }) + ); + + expect(tokenRedirectUri).toBe(PINNED_REDIRECT_URI); + // Both legs agree, which is the property the pin depends on. + expect(tokenRedirectUri).toBe(authorizationRedirectUri); + }); +}); diff --git a/apps/web/modules/auth/lib/better-auth-schema-contract.test.ts b/apps/web/modules/auth/lib/better-auth-schema-contract.test.ts new file mode 100644 index 000000000000..60fed4b83bcf --- /dev/null +++ b/apps/web/modules/auth/lib/better-auth-schema-contract.test.ts @@ -0,0 +1,181 @@ +import { getAuthTables } from "@better-auth/core/db"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import { jwt } from "better-auth/plugins/jwt"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +/** + * The schema contract between Better Auth's plugins and our Prisma models (ENG-2343). + * + * Better Auth owns these tables: each plugin declares the fields, and Better Auth writes rows through + * whichever adapter is configured — for us, Prisma. So a field a plugin declares and our model lacks is + * not a cosmetic mismatch, it is a failed INSERT: Prisma rejects the unknown argument at runtime. + * + * This class of drift nearly shipped. `jwks.alg` and `jwks.crv` arrived with the 1.7 line and our model + * did not have them, and NOTHING in the suite noticed, because nothing here writes a real row to these + * tables: the unit suites mock `@formbricks/database` wholesale, the MCP DCR harness runs on + * `memoryAdapter` (which does not enforce columns), and no integration test mints a JWK. It surfaces + * only against a real database — for `jwks`, on the first key mint of a deployment that has yet to make + * one, which takes JWT signing and the whole MCP OAuth flow down with it. + * + * So these read each plugin's own declaration rather than restating a field list, and the next field + * upstream adds fails at `pnpm test` instead of in production. + * + * The same drift is possible on the CORE tables, and there it has already bitten us twice: 1.7 keys + * accounts on `(issuer, accountId)`, and `Account.issuer` is a core field no plugin declares — so the + * plugin-only pass below would not have caught a missing column. `getAuthTables` is upstream's own + * resolver for the merged core+plugin schema, so the core assertions track whatever the installed + * version declares, exactly like the plugin ones. + * + * Scope note: this checks that our model is a SUPERSET of what the plugin declares, which is the + * direction that breaks writes. Extra columns of our own are fine and expected — `oauthClient` keeps + * the legacy `public`/`type` for the rollback path, and every model carries an `id` and Prisma relation + * fields the plugin never declares. + */ +const prismaSchemaPath = join( + dirname(fileURLToPath(import.meta.url)), + "../../../../../packages/database/schema/main.prisma" +); + +/** Field names on a Prisma model, skipping comments, block attributes and the braces. */ +const prismaModelFields = (source: string, model: string): string[] => { + const block = new RegExp(`^model ${model} \\{$([\\s\\S]*?)^\\}$`, "m").exec(source); + if (!block) throw new Error(`model ${model} not found in main.prisma`); + + return block[1] + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("//") && !line.startsWith("@@")) + .map((line) => line.split(/\s+/)[0]); +}; + +const prismaSchema = readFileSync(prismaSchemaPath, "utf8"); + +/** + * Both plugins expose their merged schema on the instance, so this tracks whatever the installed + * version declares. `oauthProvider` needs its two mandatory options to construct. + */ +const declaredModels = { + ...jwt().schema, + ...oauthProvider({ loginPage: "/auth/login", consentPage: "/account/authorize" }).schema, +} as Record }>; + +/** + * The core-table field mapping from `auth.ts`, mirrored rather than imported: importing `auth.ts` would + * construct the real instance and pull in env, Prisma and Redis, which a schema-shape unit test has no + * business booting. The mirror is guarded against the source below, so it cannot rot silently. + */ +const CORE_FIELD_MAPPING = { + session: { token: "sessionToken", expiresAt: "expires" }, + account: { + providerId: "provider", + accountId: "providerAccountId", + accessToken: "access_token", + refreshToken: "refresh_token", + idToken: "id_token", + }, +} as const; + +const coreTables = getAuthTables({ + session: { fields: { ...CORE_FIELD_MAPPING.session } }, + account: { fields: { ...CORE_FIELD_MAPPING.account } }, +}); + +/** + * Core fields Better Auth declares that we deliberately do not persist, with the mitigation that makes + * that safe. An entry here is only defensible while its mitigation is in place, so each one is asserted + * below rather than merely allowed — an exclusion nobody re-checks is how a declared field becomes a + * failing INSERT. + */ +const CORE_FIELDS_NOT_PERSISTED = { + // `User.imageUrl` was dropped in 20250813071701_remove_user_image_url. Better Auth still maps a + // provider image (Google picture / GitHub avatar / OIDC picture), and the SSO user-create hook + // strips it to `undefined` so `transformInput` drops it before Prisma sees it. + user: { image: { file: "../../ee/sso/lib/better-auth-hooks.ts", strips: "image: undefined" } }, +} as const; + +/** BA's model keys are lower-case; our Prisma models are PascalCase. */ +const prismaModelName = (model: string): string => model.charAt(0).toUpperCase() + model.slice(1); + +describe("Better Auth ↔ Prisma schema contract", () => { + describe("core tables", () => { + // Guard the mirror: a mapping changed in auth.ts and not here would make the assertions below + // compare BA's canonical names against columns we never named that way — and pass for the wrong + // reason on any field that happens to be absent from both sides. + test.each( + Object.entries(CORE_FIELD_MAPPING).flatMap(([model, fields]) => + Object.entries(fields).map(([logical, column]) => [model, logical, column]) + ) + )("auth.ts still maps %s.%s onto %s", (_model, logical, column) => { + const authSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "auth.ts"), "utf8"); + + expect(authSource).toContain(`${logical}: "${column}"`); + }); + + // `verification` is deliberately absent: with Redis `secondaryStorage` configured Better Auth keeps + // verification records there and never touches a table, so there is no Prisma model to check + // (auth.ts:70-72). `session` IS checked, because `storeSessionInDatabase` opts it back into the DB. + test.each(["user", "session", "account"])( + "our Prisma model for %s carries every core field Better Auth declares", + (model) => { + const notPersisted = Object.keys( + CORE_FIELDS_NOT_PERSISTED[model as keyof typeof CORE_FIELDS_NOT_PERSISTED] ?? {} + ); + const declared = Object.entries(coreTables[model].fields) + .filter(([name]) => !notPersisted.includes(name)) + .map(([name, attribute]) => attribute.fieldName ?? name); + const ours = prismaModelFields(prismaSchema, prismaModelName(model)); + + expect(declared.filter((field) => !ours.includes(field))).toEqual([]); + } + ); + + // Bind each exclusion above to the code that makes it safe: if the strip is removed, the field is + // back to being a missing column and the exclusion has to go with it. + test.each( + Object.entries(CORE_FIELDS_NOT_PERSISTED).flatMap(([model, fields]) => + Object.entries(fields).map(([field, { file, strips }]) => [model, field, file, strips]) + ) + )("%s.%s is still stripped before the insert", (_model, _field, file, strips) => { + const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), file), "utf8"); + + expect(source).toContain(strips); + }); + + // The field this whole file exists for: 1.7 filters account lookups on it, and it is ours to keep. + test("account.issuer is declared by Better Auth and present on our model", () => { + expect(Object.keys(coreTables.account.fields)).toContain("issuer"); + expect(prismaModelFields(prismaSchema, "Account")).toContain("issuer"); + }); + }); + + // Guard the guard: if either plugin stops exposing an introspectable schema, the per-model assertions + // below would silently pass against empty field lists and prove nothing. + test("both plugins expose the models we own", () => { + expect(Object.keys(declaredModels).sort()).toEqual([ + "jwks", + "oauthAccessToken", + "oauthClient", + "oauthClientAssertion", + "oauthClientResource", + "oauthConsent", + "oauthRefreshToken", + "oauthResource", + ]); + expect(Object.keys(declaredModels.jwks.fields ?? {})).toEqual( + expect.arrayContaining(["publicKey", "privateKey", "createdAt", "alg", "crv"]) + ); + }); + + test.each(Object.keys(declaredModels).sort())( + "our Prisma model %s carries every field its plugin declares", + (model) => { + const declared = Object.keys(declaredModels[model].fields ?? {}); + const ours = prismaModelFields(prismaSchema, model); + + expect(declared.filter((field) => !ours.includes(field))).toEqual([]); + } + ); +}); diff --git a/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts b/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts index a8de5269c859..ca60b0ccd825 100644 --- a/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts +++ b/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts @@ -1,3 +1,4 @@ +import { createLocalAccountIssuer } from "@better-auth/core/db"; import { authenticator } from "otplib"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { prisma } from "@formbricks/database"; @@ -60,6 +61,10 @@ describe("2FA secret re-encode (real Postgres)", () => { provider: "credential", providerAccountId: user.id, password: user.password!, + // Represents an already-migrated existing account (ENG-2343): real rows carry this from either + // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount + // filters on it. + issuer: createLocalAccountIssuer("credential"), }, }); @@ -112,6 +117,10 @@ describe("2FA secret re-encode (real Postgres)", () => { provider: "credential", providerAccountId: user.id, password: user.password!, + // Represents an already-migrated existing account (ENG-2343): real rows carry this from either + // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount + // filters on it. + issuer: createLocalAccountIssuer("credential"), }, }); @@ -194,6 +203,10 @@ describe("2FA secret re-encode (real Postgres)", () => { provider: "credential", providerAccountId: user.id, password: user.password!, + // Represents an already-migrated existing account (ENG-2343): real rows carry this from either + // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount + // filters on it. + issuer: createLocalAccountIssuer("credential"), }, }); @@ -250,6 +263,10 @@ describe("2FA secret re-encode (real Postgres)", () => { provider: "credential", providerAccountId: user.id, password: user.password!, + // Represents an already-migrated existing account (ENG-2343): real rows carry this from either + // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount + // filters on it. + issuer: createLocalAccountIssuer("credential"), }, }); diff --git a/apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts b/apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts new file mode 100644 index 000000000000..e518701ba849 --- /dev/null +++ b/apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, vi } from "vitest"; +import { GET } from "@/app/api/auth/[...all]/route"; +import { auth } from "@/modules/auth/lib/auth"; +import { runWithSsoRequestContext } from "@/modules/ee/sso/lib/sso-request-context"; + +/** + * ENG-2343 at the ROUTER boundary: the pinned SSO callback URL against a real Better Auth instance. + * + * `redirectURI` makes Better Auth advertise `/api/auth/oauth2/callback/{providerId}` — the URL customer + * IdPs have had registered since v5.2 — while 1.7 mounts its handler at `/callback/:id`. The unit tests + * cover the mapper's string logic and that the route calls it, but both run against a MOCKED + * `auth.handler`, so neither can show that the real router accepts the mapped path. That is the half of + * the change that would fail in production: a path the router does not match is a 404 on every SSO + * sign-in, and no amount of mapper unit-testing would reveal it. + * + * The claim asserted is exactly the claim the change makes: a request at the pinned URL is handled + * *identically* to one at the path this version serves. Comparing the two responses rather than + * hardcoding an expected status is deliberate — it stays true across Better Auth versions and does not + * encode today's particular OAuth error, while still failing loudly if the mapping stops working. + * + * `saml` is the provider under test because it is the one pinned provider configured with explicit + * endpoint URLs rather than a `discoveryUrl`, so registering it pulls in no outbound network call. State + * validation runs before any token exchange in any case, so both requests fail at the same place. + */ + +// Register the SAML generic provider: the config array is gated on ENTERPRISE_LICENSE_KEY, and the +// provider itself on SAML_OAUTH_ENABLED. Without a registered provider the callback would answer +// identically for both paths for the *wrong* reason (an unknown provider), and the comparison below +// would pass while proving nothing. +vi.mock("@/lib/constants", async (importOriginal) => ({ + ...(await importOriginal()), + ENTERPRISE_LICENSE_KEY: "integration-license", + SAML_OAUTH_ENABLED: true, +})); + +const BASE = "http://localhost:3000"; +const QUERY = "code=integration-code&state=integration-state"; + +const summarize = async (response: Response) => ({ + status: response.status, + location: response.headers.get("location"), +}); + +/** Through the mounted route, so the mapper is in the path — the production call. */ +const viaPinnedUrl = (path: string): Promise => GET(new Request(`${BASE}${path}?${QUERY}`)); + +/** Straight at Better Auth, bypassing the mapper — the control. */ +const viaHandler = (path: string): Promise => + runWithSsoRequestContext(() => auth.handler(new Request(`${BASE}${path}?${QUERY}`))); + +describe("pinned SSO callback URL reaches Better Auth's callback route", () => { + test("the pinned path is handled exactly as the path this version serves", async () => { + const [pinned, current] = await Promise.all([ + viaPinnedUrl("/api/auth/oauth2/callback/saml").then(summarize), + viaHandler("/api/auth/callback/saml").then(summarize), + ]); + + expect(pinned).toEqual(current); + }); + + /** + * The control that gives the assertion above its meaning: without the mapper the pinned path is a 404, + * because no Better Auth 1.7 route is mounted under `/oauth2/callback/`. An unpinned provider id takes + * exactly that route, so this pins down *why* the comparison passes. + */ + test("an unpinned provider id is not mapped and 404s", async () => { + const response = await viaPinnedUrl("/api/auth/oauth2/callback/not-a-pinned-provider"); + + expect(response.status).toBe(404); + }); + + test("the pinned path is reached, not 404ed", async () => { + const response = await viaPinnedUrl("/api/auth/oauth2/callback/saml"); + + expect(response.status).not.toBe(404); + }); +}); diff --git a/apps/web/modules/auth/lib/legacy-sso-callback.test.ts b/apps/web/modules/auth/lib/legacy-sso-callback.test.ts new file mode 100644 index 000000000000..b30404438fd7 --- /dev/null +++ b/apps/web/modules/auth/lib/legacy-sso-callback.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "vitest"; +import { + PINNED_SSO_PROVIDER_IDS, + mapLegacySsoCallbackRequest, + mapLegacySsoCallbackUrl, +} from "./legacy-sso-callback"; + +const BASE = "https://app.formbricks.test"; + +describe("mapLegacySsoCallbackUrl (ENG-2343)", () => { + test.each(PINNED_SSO_PROVIDER_IDS)("maps the pinned legacy callback for %s", (providerId) => { + expect(mapLegacySsoCallbackUrl(`${BASE}/api/auth/oauth2/callback/${providerId}`)).toBe( + `${BASE}/api/auth/callback/${providerId}` + ); + }); + + // The query is the whole point of a callback — dropping it would strip `code`/`state` and turn every + // SSO sign-in into a silent failure that looks like an IdP problem. + test("carries the authorization code and state across untouched", () => { + const mapped = mapLegacySsoCallbackUrl( + `${BASE}/api/auth/oauth2/callback/openid?code=abc%2F123&state=xyz&iss=${encodeURIComponent(BASE)}` + ); + + const url = new URL(mapped ?? ""); + expect(url.pathname).toBe("/api/auth/callback/openid"); + expect(url.searchParams.get("code")).toBe("abc/123"); + expect(url.searchParams.get("state")).toBe("xyz"); + expect(url.searchParams.get("iss")).toBe(BASE); + }); + + // A Next.js basePath deployment serves the app from a subpath, so the auth segment is not at the root + // of the pathname. Same reasoning as better-auth-path-label.ts (see ENG-606). + test("resolves under a basePath deployment", () => { + expect(mapLegacySsoCallbackUrl(`${BASE}/custom-path/api/auth/oauth2/callback/saml`)).toBe( + `${BASE}/custom-path/api/auth/callback/saml` + ); + }); + + // The crafted-prefix guard must not also reject a legitimate basePath that merely STARTS with the auth + // path — matching on `/api/auth` without the trailing slash would 404 SSO on such a deployment. + test("resolves under a basePath that starts with the auth path", () => { + expect(mapLegacySsoCallbackUrl(`${BASE}/api/authority/api/auth/oauth2/callback/openid`)).toBe( + `${BASE}/api/authority/api/auth/callback/openid` + ); + }); + + /** + * The scoping that makes this safe to run in the `/api/auth/*` catch-all. The oauth-provider plugin + * owns roughly fifteen sibling `/oauth2/*` routes for our own MCP OAuth server; an unscoped prefix + * rewrite would shadow whichever one upstream adds next. Everything not an exact pinned provider id + * must pass through untouched. + */ + test.each([ + ["a sibling MCP OAuth route", `${BASE}/api/auth/oauth2/userinfo`], + ["the MCP consent route", `${BASE}/api/auth/oauth2/consent`], + ["the current-version callback", `${BASE}/api/auth/callback/openid`], + ["an unpinned provider id", `${BASE}/api/auth/oauth2/callback/google`], + ["a deeper path under a pinned id", `${BASE}/api/auth/oauth2/callback/openid/extra`], + // Rejected here as defence in depth only: Next.js 308-normalises a trailing slash (and doubled + // slashes) to the canonical path before the route handler runs, so in production this shape reaches + // the mapper already canonicalised and IS mapped. Verified against a running dev server. + ["a trailing slash", `${BASE}/api/auth/oauth2/callback/openid/`], + // Keeps the basePath tolerance from accepting a crafted double auth segment, so the only path this + // function can emit is `/api/auth/callback/`. + ["a second auth segment in the prefix", `${BASE}/api/auth/x/api/auth/oauth2/callback/openid`], + ["no provider id at all", `${BASE}/api/auth/oauth2/callback/`], + ["an unrelated endpoint", `${BASE}/api/auth/sign-in/email`], + ["a non-auth route", `${BASE}/api/v3/surveys`], + ["a percent-encoded provider id", `${BASE}/api/auth/oauth2/callback/openi%64`], + ["percent-encoded separators", `${BASE}/api/auth/oauth2%2fcallback%2fopenid`], + ["an upper-cased path", `${BASE}/api/auth/OAUTH2/CALLBACK/OPENID`], + ["an unparseable url", "not-a-url"], + // A cannot-be-a-base URL: the `pathname` setter is a no-op there, so without the protocol guard + // this would come back unchanged yet non-null — a non-rewrite reported as a rewrite. + ["an opaque, cannot-be-a-base url", "data:text/plain,/api/auth/oauth2/callback/openid"], + ])("leaves %s alone", (_label, url) => { + expect(mapLegacySsoCallbackUrl(url)).toBeNull(); + }); +}); + +describe("mapLegacySsoCallbackUrl — normalisation order (ENG-2343)", () => { + // `new URL()` resolves dot segments at construction, so matching runs on the normalised path. That is + // the safe order: a traversal cannot be smuggled past the match, it just canonicalises into it. + test.each([ + `${BASE}/api/auth/oauth2/callback/../callback/openid`, + `${BASE}/api/auth/oauth2/callback/x/../openid`, + ])("normalises dot segments before matching: %s", (url) => { + expect(mapLegacySsoCallbackUrl(url)).toBe(`${BASE}/api/auth/callback/openid`); + }); +}); + +describe("mapLegacySsoCallbackRequest (ENG-2343)", () => { + test("rewrites a GET callback and preserves method and headers", () => { + const request = new Request(`${BASE}/api/auth/oauth2/callback/azuread?code=abc`, { + headers: { cookie: "better-auth.state=s" }, + }); + + const mapped = mapLegacySsoCallbackRequest(request); + + expect(mapped.url).toBe(`${BASE}/api/auth/callback/azuread?code=abc`); + expect(mapped.method).toBe("GET"); + // Carrying the cookie is load-bearing: Better Auth reads the state/PKCE cookie on the callback, so + // dropping it would fail the sign-in as a state mismatch. + expect(mapped.headers.get("cookie")).toBe("better-auth.state=s"); + }); + + // An IdP configured for `response_mode=form_post` returns the code as a POST body. + test("forwards a POST body for a form_post response mode", async () => { + const request = new Request(`${BASE}/api/auth/oauth2/callback/azuread`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "code=abc&state=xyz", + }); + + const mapped = mapLegacySsoCallbackRequest(request); + + expect(mapped.url).toBe(`${BASE}/api/auth/callback/azuread`); + expect(mapped.method).toBe("POST"); + await expect(mapped.text()).resolves.toBe("code=abc&state=xyz"); + }); + + // Rebuilding a Request drops everything not copied. Behavioural rather than identity-based: the spec + // lets an implementation wrap the passed signal rather than reuse the object. + test("carries the abort signal so a client disconnect still cancels the handler", () => { + const controller = new AbortController(); + const request = new Request(`${BASE}/api/auth/oauth2/callback/openid?code=abc`, { + signal: controller.signal, + }); + + const mapped = mapLegacySsoCallbackRequest(request); + + expect(mapped.signal.aborted).toBe(false); + controller.abort(); + expect(mapped.signal.aborted).toBe(true); + }); + + test("returns the original request untouched when the path is not a pinned callback", () => { + const request = new Request(`${BASE}/api/auth/sign-in/email`, { method: "POST" }); + + expect(mapLegacySsoCallbackRequest(request)).toBe(request); + }); +}); diff --git a/apps/web/modules/auth/lib/legacy-sso-callback.ts b/apps/web/modules/auth/lib/legacy-sso-callback.ts new file mode 100644 index 000000000000..b6a951d601c5 --- /dev/null +++ b/apps/web/modules/auth/lib/legacy-sso-callback.ts @@ -0,0 +1,117 @@ +import "server-only"; + +/** + * Keep serving the SSO callback URL that customer IdPs have had registered since v5.2, whatever path + * the installed Better Auth actually mounts its handler on (ENG-2343). + * + * Better Auth has moved this path twice, neither time by our choice: the 1.6 `genericOAuth` plugin + * mounted its own `/oauth2/callback/:providerId` route, and 1.7 rebuilt that plugin onto the built-in + * `/callback/:id` route. Each move otherwise forces every self-hoster to re-register a redirect URI at + * their IdP, which is the friction this module exists to end. + * + * The fix has two halves. `better-auth-providers.ts` pins `redirectURI` so Better Auth *advertises* the + * v5.2 URL — but that option does not move the route the handler is mounted on, and it is an upstream + * option we could lose. This half maps the advertised URL onto the path the installed version serves, + * and it is entirely ours: no upstream release can take it away. So if the pin ever stops working, SSO + * fails at the IdP with a `redirect_uri` mismatch (loud, and caught by + * better-auth-redirect-uri-pin.test.ts at upgrade time) rather than half-working. + * + * Deliberately dependency-free — no env, no license, no `auth` import — so it is exhaustively testable + * and so it works during the window where the provider list is empty (the generic providers are gated + * behind `ENTERPRISE_LICENSE_KEY`). + */ + +const AUTH_BASE_PATH = "/api/auth"; +const LEGACY_CALLBACK_SEGMENT = `${AUTH_BASE_PATH}/oauth2/callback/`; +const CURRENT_CALLBACK_SEGMENT = `${AUTH_BASE_PATH}/callback/`; + +/** + * The generic-OAuth providers whose `redirectURI` is pinned to the legacy path. Kept as a local literal + * rather than derived from `ssoGenericOAuthConfig`: that list is env- and license-gated and is empty on + * an unlicensed instance, whereas this mapping must hold for any request that arrives. A test asserts + * the two agree, so they cannot drift. + * + * Scoping to known ids is what keeps this safe: the oauth-provider plugin owns ~15 sibling `/oauth2/*` + * routes (`/oauth2/consent`, `/oauth2/userinfo`, `/oauth2/token`, …) for our own MCP OAuth server, and + * an unscoped prefix rewrite could shadow one that upstream adds later. + */ +export const PINNED_SSO_PROVIDER_IDS = ["azuread", "openid", "saml"] as const; + +/** + * The current-version URL for a legacy SSO callback request, or `null` when the request is not one. + * + * Matched as a SUFFIX, with the provider id exact and the prefix required to hold no second `/api/auth`. + * Together those two conditions make this function's output a local invariant rather than something the + * router has to clean up after: the only path it can ever produce is + * `/api/auth/callback/`. A suffix rather than an anchored prefix because a Next.js + * `basePath` deployment serves the app from a subpath, so the auth segment is not at position 0 — the + * same reason `better-auth-path-label.ts` locates it with `indexOf` rather than `startsWith` (see + * ENG-606); the single-auth-segment rule is what keeps that tolerance from also accepting a crafted + * `/api/auth/x/api/auth/oauth2/callback/openid`. That one would be harmless anyway — the rewrite only ever + * deletes an `/oauth2` segment, so it cannot reach an endpoint the caller could not already reach, and the + * result 404s — but this runs inside the `/api/auth/*` catch-all, where "harmless because the router + * rejects it" is a property worth owning here instead of inheriting. + * + * `/oauth2/callback/azuread/extra` and a trailing-slash form are both left alone: an IdP redirects to + * precisely the URI it has registered, and an auth path is the wrong place to invent equivalences. Query + * and fragment carry over untouched — the query is where `code` and `state` live. + */ +export const mapLegacySsoCallbackUrl = (requestUrl: string): string | null => { + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return null; + } + + // Only http(s). On a cannot-be-a-base URL (`data:`, `mailto:`) the `pathname` setter is a silent no-op, + // so the rewrite below would return the input unchanged — a non-rewrite escaping as a rewrite. Next + // only ever hands us http(s), but this is an exported pure function whose docblock states an + // unconditional invariant, so it should hold unconditionally. + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + + const { pathname } = url; + const providerId = PINNED_SSO_PROVIDER_IDS.find((id) => + pathname.endsWith(`${LEGACY_CALLBACK_SEGMENT}${id}`) + ); + if (providerId === undefined) return null; + + const prefix = pathname.slice(0, pathname.length - (LEGACY_CALLBACK_SEGMENT.length + providerId.length)); + if (prefix.includes(`${AUTH_BASE_PATH}/`)) return null; + + url.pathname = `${prefix}${CURRENT_CALLBACK_SEGMENT}${providerId}`; + return url.toString(); +}; + +/** + * The request Better Auth should handle: rewritten when it names a pinned legacy SSO callback, and the + * original object otherwise (identity, so the common path allocates nothing). + * + * A rewrite rather than a redirect, so the single-use authorization `code` is not re-emitted in a + * `Location` header on the GET callback that every one of our providers actually uses. Note this does not + * hold for `response_mode=form_post`: Better Auth 1.7 itself 302s a POST callback to + * `${baseURL}/callback/{id}?code=…&state=…` before validating state (`api/routes/callback.mjs`), so on + * that path the code travels through a `Location` regardless of what we do here — which is also why the + * body still has to be forwarded below rather than dropped. + */ +export const mapLegacySsoCallbackRequest = (request: Request): Request => { + const mappedUrl = mapLegacySsoCallbackUrl(request.url); + if (mappedUrl === null) return request; + + // GET/HEAD cannot carry a body; anything else (an IdP configured for `response_mode=form_post`) + // forwards the stream, which undici requires `duplex: "half"` for. `duplex` is absent from TypeScript's + // `RequestInit`, hence the cast. + const forwardsBody = request.method !== "GET" && request.method !== "HEAD"; + return new Request(mappedUrl, { + method: request.method, + headers: request.headers, + // Rebuilding a Request keeps nothing that is not copied. Without this a client disconnect stops + // aborting `auth.handler` and its outbound IdP calls on the pinned path only — the pass-through path + // returns the original object and does keep it, so omitting it gives the two paths different abort + // behaviour. + signal: request.signal, + // `duplex` is absent from TypeScript's RequestInit; cast only that property so `method`, `headers` + // and `body` above keep their checking. + ...(forwardsBody ? { body: request.body, ...({ duplex: "half" } as RequestInit) } : {}), + }); +}; diff --git a/apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts b/apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts new file mode 100644 index 000000000000..3be4a2fbaa0f --- /dev/null +++ b/apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "vitest"; +import { + isDcrRegistration, + normalizeDcrRequest, + withInferredApplicationType, +} from "./mcp-dcr-application-type"; + +const BASE = "https://app.formbricks.test"; +const REGISTER = `${BASE}/api/auth/oauth2/register`; + +/** + * ENG-2343. Better Auth 1.7 hardcodes `application_type: "web"` for dynamic client registration, and a + * web client is refused any loopback redirect URI — which is exactly what a local MCP client uses. 1.6 + * had no such validation, so a client that omits the field regressed from working to + * `400 invalid_redirect_uri` before consent. Neither the default nor the clients are ours to change, so + * the field is inferred here when the URIs make it unambiguous. + */ +describe("withInferredApplicationType (ENG-2343)", () => { + test("fills in native when a redirect URI is an http loopback", () => { + const body = JSON.stringify({ redirect_uris: ["http://127.0.0.1:33418/callback"] }); + + expect(JSON.parse(withInferredApplicationType(body))).toEqual({ + redirect_uris: ["http://127.0.0.1:33418/callback"], + application_type: "native", + }); + }); + + // The three hosts upstream itself accepts for native http, so the value we supply is guaranteed to + // pass the validation that runs immediately after. + test.each(["http://localhost:8080/cb", "http://127.0.0.1:1/cb", "http://[::1]:9000/cb"])( + "treats %s as native loopback", + (uri) => { + const result = JSON.parse(withInferredApplicationType(JSON.stringify({ redirect_uris: [uri] }))); + + expect(result.application_type).toBe("native"); + } + ); + + /** + * Everything else is passed through so upstream decides, exactly as before. Inferring must never be + * the reason a registration succeeds that should have failed, nor the reason one fails at all. + */ + test.each([ + [ + "an explicit application_type is never overridden", + { application_type: "web", redirect_uris: ["http://127.0.0.1:1/cb"] }, + ], + ["a non-loopback https URI", { redirect_uris: ["https://app.example.com/cb"] }], + ["https on loopback (upstream refuses this for native)", { redirect_uris: ["https://127.0.0.1:1/cb"] }], + ["a non-loopback http host", { redirect_uris: ["http://10.0.0.5:1/cb"] }], + ["no redirect_uris at all", { client_name: "x" }], + ["an empty redirect_uris array", { redirect_uris: [] }], + ["a non-string entry", { redirect_uris: [42] }], + ])("leaves %s untouched", (_label, payload) => { + const body = JSON.stringify(payload); + + expect(withInferredApplicationType(body)).toBe(body); + }); + + /** + * A native client may register a loopback URI *and* an https one (an app-claimed universal link). + * Upstream accepts that pair under `native`, and 1.6 accepted it unconditionally, so failing to infer + * here would newly break it — the regression this file exists to prevent. Widening to "at least one" + * cannot widen what upstream accepts: it refuses a non-loopback http redirect under `native` too, as + * the case below asserts. + */ + test.each([ + ["loopback alongside an https URI", ["http://127.0.0.1:1/cb", "https://app.example.com/cb"]], + ["an https URI listed first", ["https://app.example.com/cb", "http://localhost:7777/cb"]], + ])("infers native for %s", (_label, redirect_uris) => { + const result = JSON.parse(withInferredApplicationType(JSON.stringify({ redirect_uris }))); + + expect(result.application_type).toBe("native"); + expect(result.redirect_uris).toEqual(redirect_uris); + }); + + // The security boundary the widening leans on: labelling a client `native` must not be a way to get a + // non-loopback http redirect registered. We still infer here, and upstream still refuses the URI — + // asserted end-to-end against the real validator in mcp-oauth-dcr.test.ts. + test("inferring native does not make a non-loopback http redirect acceptable", () => { + const redirect_uris = ["http://127.0.0.1:1/cb", "http://evil.example.com/cb"]; + const result = JSON.parse(withInferredApplicationType(JSON.stringify({ redirect_uris }))); + + expect(result.application_type).toBe("native"); + expect(result.redirect_uris).toEqual(redirect_uris); + }); + + // A malformed body must reach upstream unchanged and produce upstream's own error, not ours. + test.each(["not json", "[1,2,3]", "null", '"a string"'])("passes through %s unchanged", (body) => { + expect(withInferredApplicationType(body)).toBe(body); + }); +}); + +describe("isDcrRegistration", () => { + test("matches a POST to the registration endpoint", () => { + expect(isDcrRegistration(new Request(REGISTER, { method: "POST", body: "{}" }))).toBe(true); + }); + + test.each([ + ["a GET", new Request(REGISTER)], + [ + "a sibling MCP OAuth route", + new Request(`${BASE}/api/auth/oauth2/token`, { method: "POST", body: "{}" }), + ], + [ + "the SSO callback", + new Request(`${BASE}/api/auth/oauth2/callback/openid`, { method: "POST", body: "{}" }), + ], + ["an unrelated endpoint", new Request(`${BASE}/api/auth/sign-in/email`, { method: "POST", body: "{}" })], + ])("does not match %s", (_label, request) => { + expect(isDcrRegistration(request)).toBe(false); + }); +}); + +describe("normalizeDcrRequest", () => { + test("rebuilds the registration with the inferred type and keeps the headers", async () => { + const request = new Request(REGISTER, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer t" }, + body: JSON.stringify({ redirect_uris: ["http://127.0.0.1:33418/callback"] }), + }); + + const normalized = await normalizeDcrRequest(request); + + expect(normalized.headers.get("authorization")).toBe("Bearer t"); + await expect(normalized.json()).resolves.toMatchObject({ application_type: "native" }); + }); + + // A Request body is single-use, so the normalizer has to reconstruct even when it changes nothing — + // otherwise the body it consumed would be gone by the time Better Auth reads it. + test("still yields a readable body when nothing is inferred", async () => { + const body = JSON.stringify({ redirect_uris: ["https://app.example.com/cb"] }); + const normalized = await normalizeDcrRequest(new Request(REGISTER, { method: "POST", body })); + + await expect(normalized.text()).resolves.toBe(body); + }); + + test("returns the original object for a request it does not handle", async () => { + const request = new Request(`${BASE}/api/auth/sign-in/email`, { method: "POST", body: "{}" }); + + expect(await normalizeDcrRequest(request)).toBe(request); + }); +}); diff --git a/apps/web/modules/auth/lib/mcp-dcr-application-type.ts b/apps/web/modules/auth/lib/mcp-dcr-application-type.ts new file mode 100644 index 000000000000..26171baf6536 --- /dev/null +++ b/apps/web/modules/auth/lib/mcp-dcr-application-type.ts @@ -0,0 +1,96 @@ +import "server-only"; + +/** + * Default `application_type` to `"native"` on Dynamic Client Registration when the client asked for + * loopback redirect URIs and did not say which kind of client it is (ENG-2343). + * + * Better Auth 1.7 added redirect-URI validation that 1.6 did not have, and for dynamic registration it + * hardcodes the `application_type` default to `"web"` + * (`@better-auth/oauth-provider` — `applyOAuthClientRegistrationDefaults(client, … : "web")`, then + * `validateClientRedirectUri(uri, applicationType ?? "web")`). A `"web"` client is refused any loopback + * URI outright: `if (!isHttps || isRedirectLoopback) invalidRedirectUri(...)`. + * + * Loopback is exactly what a local MCP client uses — `http://127.0.0.1:/callback` — and the MCP + * SDK posts the client's metadata verbatim, so a client that omits `application_type` (MCP Inspector's + * shape) would get `400 invalid_redirect_uri` before consent on 1.7 having worked on 1.6. There is no + * plugin option for the default: it is a literal at the call site. Self-hosters cannot fix it either, + * because the clients are not theirs to change — so it is normalized here. + * + * The inference is narrow and spec-aligned. RFC 8252 §7.3 defines loopback redirection as the native-app + * pattern, so a registration that asks for one is a native client; a browser app would not. We fill the + * field in only when it is absent and at least one redirect URI is http on one of the three hosts + * upstream itself accepts for native (`localhost`, `127.0.0.1`, `[::1]`). Anything else is passed + * through untouched and upstream decides, exactly as before. + * + * Deliberately "at least one" rather than "all": a native client may legitimately register a loopback + * URI *and* an https one (an app-claimed universal link), a shape upstream accepts under `native` and + * 1.6 accepted unconditionally — requiring every URI to be loopback would have made that combination + * newly fail, which is the regression this whole file exists to prevent. Verified against the live + * endpoint that widening this does not widen what gets accepted: upstream refuses a non-loopback http + * redirect under `native` too (`native` + `http://evil.example.com` → `invalid_redirect_uri`), so the + * only URIs this can green-light are loopback and https ones. It never turns a rejected URI into an + * accepted one; it only stops a native client being misfiled as a web one. + */ + +const DCR_PATH_SEGMENT = "/api/auth/oauth2/register"; +const NATIVE_HTTP_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +const isNativeHttpLoopback = (uri: unknown): boolean => { + if (typeof uri !== "string") return false; + try { + const url = new URL(uri); + return url.protocol === "http:" && NATIVE_HTTP_LOOPBACK_HOSTS.has(url.hostname); + } catch { + return false; + } +}; + +/** Whether this request is a dynamic client registration whose body we should look at. */ +export const isDcrRegistration = (request: Request): boolean => { + if (request.method !== "POST") return false; + try { + return new URL(request.url).pathname.endsWith(DCR_PATH_SEGMENT); + } catch { + return false; + } +}; + +/** + * The registration body with `application_type: "native"` filled in when it was absent and at least one + * redirect URI is an http loopback. Returns the input unchanged in every other case, including a body + * that is not JSON or not an object — this must never be the reason a registration fails. + */ +export const withInferredApplicationType = (body: string): string => { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return body; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return body; + + const client = parsed as Record; + if (client.application_type !== undefined) return body; + + const redirectUris = client.redirect_uris; + if (!Array.isArray(redirectUris) || redirectUris.length === 0) return body; + if (!redirectUris.some(isNativeHttpLoopback)) return body; + + return JSON.stringify({ ...client, application_type: "native" }); +}; + +/** + * The request Better Auth should handle. Reads the body only for a DCR POST, and always reconstructs + * with the body it read — a Request body is single-use, so it cannot be inspected and then reused. + */ +export const normalizeDcrRequest = async (request: Request): Promise => { + if (!isDcrRegistration(request)) return request; + + const raw = await request.text(); + return new Request(request.url, { + method: request.method, + headers: request.headers, + body: withInferredApplicationType(raw), + signal: request.signal, + }); +}; diff --git a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts index 73466cfbe7ec..cdc9f533c7b8 100644 --- a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts +++ b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts @@ -5,8 +5,9 @@ import { jwt } from "better-auth/plugins"; import { NextRequest } from "next/server"; import { describe, expect, test, vi } from "vitest"; import { GET as getProtectedResourceMetadata } from "@/app/.well-known/oauth-protected-resource/[[...resource]]/route"; +import { withInferredApplicationType } from "./mcp-dcr-application-type"; import { getMcpOauthProviderOptions } from "./mcp-oauth-provider-options"; -import { getAuthIssuerUrl, getMcpResourceUrl } from "./oauth-urls"; +import { getAuthIssuerUrl, getMcpResourceUrl, getOAuthUserInfoUrl } from "./oauth-urls"; // Env-dependent URL getters pinned; scope constants stay real — the whole point of this suite // is to exercise the actual advertised-scope → DCR → authorize chain (ENG-1055). @@ -32,7 +33,23 @@ const REDIRECT_URI = "http://127.0.0.1:33418/callback"; * full-scope client would mask that bug, so this suite must register via DCR only. */ const createAuthInstance = () => { - const db = {}; + // memoryAdapter needs every model it will touch declared up front — it does not create them + // lazily. Better Auth 1.7 added the resource tables, and without them the plugin's boot-time + // resource seeding logs `Model oauthResource not found in the DB` and every authorize fails. + const db: Record = { + user: [], + session: [], + account: [], + verification: [], + jwks: [], + oauthClient: [], + oauthAccessToken: [], + oauthRefreshToken: [], + oauthConsent: [], + oauthResource: [], + oauthClientResource: [], + oauthClientAssertion: [], + }; return betterAuth({ baseURL: BASE_URL, secret: "mcp-oauth-dcr-test-secret", @@ -68,6 +85,11 @@ const registerClient = async (auth: ReturnType, scope grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", + // Better Auth 1.7 validates redirect URIs against the OIDC application type, and DCR without + // an explicit `application_type` defaults to "web" — for which ANY loopback redirect URI is + // refused. MCP clients listen on a loopback port, so they are native clients and must say so. + // See the sibling test below, which pins the refusal. + application_type: "native", scope: scopes.join(" "), }), }) @@ -101,7 +123,105 @@ const requestAuthorize = async ( describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape)", () => { test("limits access tokens to the single MCP resource audience", () => { - expect(getMcpOauthProviderOptions().validAudiences).toEqual([getMcpResourceUrl()]); + const { resources } = getMcpOauthProviderOptions(); + + expect(resources).toHaveLength(1); + expect(resources?.[0]).toMatchObject({ identifier: getMcpResourceUrl() }); + }); + + /** + * The MCP resource server allow-lists the AS's UserInfo endpoint as a second acceptable audience, + * because the provider appends it to `aud` whenever `openid` is in the granted scopes. Every other + * test compares our derivation of that URL to our own derivation, which would hold for any string + * — including a wrong one. Asserting against the instance's own discovery document is what pins the + * equality the allow-list actually depends on: the provider builds `userinfo_endpoint` and the + * appended audience from the same `${baseURL}/oauth2/userinfo` expression. + */ + test("the UserInfo audience we allow-list is the one the provider stamps", async () => { + const auth = createAuthInstance(); + + const response = await auth.handler(new Request(`${BASE_URL}/api/auth/.well-known/openid-configuration`)); + const { userinfo_endpoint: userinfoEndpoint } = (await response.json()) as { + userinfo_endpoint: string; + }; + + expect(userinfoEndpoint).toBe(getOAuthUserInfoUrl()); + }); + + /** + * The 1.7 redirect-URI rules (ENG-2343), and the fix for them. + * + * An MCP client registers a loopback callback such as http://127.0.0.1:PORT/callback. Under 1.7 that + * is legal only for a *native* client: DCR hardcodes the `application_type` default to "web", and + * `validateClientRedirectUri` refuses every loopback URI for web clients — so a client that omits the + * field is rejected before the user ever sees a consent screen. 1.6 had no such validation, so this + * regressed working clients, and neither the default (a literal at the call site, not an option) nor + * the clients are ours to change. + * + * These two tests are a pair: the first pins what upstream does, which is why the normalizer exists; + * the second proves the normalizer actually resolves it against that same real validator. Note the + * body is IDENTICAL in both — only `withInferredApplicationType` is applied. + */ + const LOOPBACK_REGISTRATION = JSON.stringify({ + client_name: "MCP DCR client that omits application_type", + redirect_uris: [REDIRECT_URI], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "surveys:read", + }); + + const register = (auth: ReturnType, body: string) => + auth.handler( + new Request(`${BASE_URL}/api/auth/oauth2/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }) + ); + + test("upstream refuses a loopback redirect URI when the client does not declare itself native", async () => { + const response = await register(createAuthInstance(), LOOPBACK_REGISTRATION); + const body = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("invalid_redirect_uri"); + }); + + test("the inferred application_type makes that same registration succeed", async () => { + const response = await register(createAuthInstance(), withInferredApplicationType(LOOPBACK_REGISTRATION)); + const body = (await response.json()) as { client_id?: string; application_type?: string; error?: string }; + + expect(body.error).toBeUndefined(); + expect(response.status).toBeLessThan(300); + expect(body.client_id).toBeTruthy(); + }); + + /** + * The security boundary the inference leans on. It fires whenever ANY redirect URI is http loopback, + * which is deliberately wider than "all of them" — a native client may legitimately pair a loopback + * URI with an https one. That widening is only safe because `native` does not relax the rule for a + * non-loopback http URI, so being labelled native can never be a route to registering one. Asserted + * against the real validator rather than reasoned about, because the whole class of bug here is + * upstream changing a rule we assumed. + */ + test("being labelled native does not let a non-loopback http redirect register", async () => { + const payload = JSON.stringify({ + client_name: "Mixed Client", + redirect_uris: ["http://127.0.0.1:9999/callback", "http://evil.example.com/callback"], + token_endpoint_auth_method: "none", + }); + const inferred = withInferredApplicationType(payload); + + // The inference does fire on this shape … + expect(JSON.parse(inferred).application_type).toBe("native"); + + // … and upstream still refuses the registration. + const response = await register(createAuthInstance(), inferred); + const body = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("invalid_redirect_uri"); }); test("PRM-advertised scopes register verbatim, including offline_access", async () => { @@ -112,7 +232,8 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape const registration = await registerClient(auth, advertisedScopes); - expect(registration.status).toBe(200); + // 201 Created since 1.7 (RFC 7591 §3.2.1). + expect(registration.status).toBe(201); expect(registration.body.client_id).toBeTruthy(); // The registered scope set is what /authorize validates against — offline_access must survive. expect(registration.body.scope?.split(" ")).toEqual(expect.arrayContaining(advertisedScopes)); @@ -134,12 +255,14 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", + application_type: "native", }), }) ); const body = (await response.json()) as { scope?: string }; - expect(response.status).toBe(200); + // Better Auth 1.7 returns 201 Created here, per RFC 7591 §3.2.1; 1.6 answered 200. + expect(response.status).toBe(201); expect(body.scope?.split(" ")).toEqual( expect.arrayContaining([ "surveys:read", @@ -170,13 +293,52 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape expect(authorize.location).toContain("/auth/login"); }); - test("authorize still rejects scopes outside the client's registration", async () => { + /** + * Behaviour change in Better Auth 1.7, pinned deliberately (ENG-2343). + * + * In 1.6 a client was registered with exactly the scopes it asked for, so a client that requested + * `surveys:read` could not later authorize `offline_access` — authorize answered `invalid_scope`. + * In 1.7 `clientRegistrationDefaultScopes` is applied regardless of what the client requested, so + * every DCR client is registered with the full advertised set and a narrower request no longer + * constrains it. + * + * That removes a boundary: a client can no longer self-limit at registration. It does NOT grant + * anything by itself — the token still only carries the scopes the user approves at consent, the + * per-tool guards check the token's scopes, and workspace permissions bound what those can reach. + * But "registered read-only" is no longer a thing, so it is asserted here rather than assumed. + */ + test("registration grants the full default scope set even when the client asks for less", async () => { const auth = createAuthInstance(); + const registration = await registerClient(auth, ["surveys:read"]); const clientId = registration.body.client_id; expect(clientId).toBeTruthy(); + expect(registration.body.scope?.split(" ")).toEqual(expect.arrayContaining(["surveys:write"])); + + // Consequence: a scope the client never requested is now accepted at authorize. + // + // Asserted as a positive outcome, not as the absence of one error string. `requestAuthorize` + // defaults `location` to "" when the header is missing, and "" satisfies every `not.toContain` — + // so a negative assertion here would also pass if authorize returned a different error, or no + // redirect at all. What an accepted request actually does, unauthenticated, is bounce to the + // configured loginPage carrying no `error`. const authorize = await requestAuthorize(auth, clientId as string, ["surveys:read", "offline_access"]); + expect(authorize.location).toBeTruthy(); + + const location = new URL(authorize.location, BASE_URL); + expect(location.pathname).toBe("/auth/login"); + expect(location.searchParams.get("error")).toBeNull(); + }); + + test("authorize still rejects a scope outside the advertised set entirely", async () => { + const auth = createAuthInstance(); + const registration = await registerClient(auth, ["surveys:read"]); + + const authorize = await requestAuthorize(auth, registration.body.client_id as string, [ + "surveys:read", + "billing:admin", + ]); expect(authorize.location).toContain("error=invalid_scope"); }); diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts index 268965fb0c0d..888849e928ad 100644 --- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts +++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts @@ -14,25 +14,49 @@ vi.mock("@/lib/env", () => ({ })); /** - * `validAudiences` is the single mitigation standing between this deployment and - * GHSA-p2fr-6hmx-4528, and until now nothing asserted it. + * The registered resource set is what binds an access token's audience to what the user approved + * (GHSA-p2fr-6hmx-4528). Better Auth 1.7 replaced the flat `validAudiences` allow-list with this + * model: a token is issued for a resource the grant covers rather than for whatever the client asked + * for. Declaring a second resource here would make cross-resource escalation possible again, so the + * single entry is asserted rather than assumed. * - * The provider does not bind an access token's `aud` to the resource approved at authorization: it - * stamps the token with the whole `validAudiences` allow-list. With one entry there is no second - * audience to escalate into, so the advisory cannot bite here. Add a second entry and it can — - * silently, with every existing test still green. That is what this suite exists to stop. - * - * The resource server enforces the other half (rejecting a token that names an audience beyond this - * one) in modules/mcp/auth.ts, which is required by RFC 9068 §4 no matter how the provider behaves. + * The resource server enforces the other half — refusing a token whose `aud` names anything beyond + * this resource and the AS's own UserInfo endpoint — in modules/mcp/auth.ts. RFC 9068 §4 puts that + * on the resource server regardless of how the provider behaves, and 1.7 makes it more load-bearing: + * the provider no longer checks the audience against the *calling* resource server at all. */ describe("getMcpOauthProviderOptions", () => { // Also pinned in mcp-oauth-dcr.test.ts (#8828). The duplication is deliberate: this is the // invariant the whole GHSA-p2fr-6hmx-4528 acceptance rests on, and the two suites can be deleted // or rewritten independently. Do not "de-duplicate" this away. - test("grants exactly one audience, so no token can be minted for a second resource server", () => { - const { validAudiences } = getMcpOauthProviderOptions(); + test("registers exactly one resource, so no token can be minted for a second resource server", () => { + const { resources } = getMcpOauthProviderOptions(); + + expect(resources).toHaveLength(1); + expect(resources?.[0]).toMatchObject({ identifier: getMcpResourceUrl() }); + }); + + // enforcePerClientResources defaults to true, so a DCR client with no linked resource is refused + // `invalid_target` at the token endpoint — after the user has consented. This must stay in step + // with the registered resource above. + test("links every newly registered client to that same resource", () => { + const { clientRegistrationDefaultResources } = getMcpOauthProviderOptions(); + + expect(clientRegistrationDefaultResources).toEqual([getMcpResourceUrl()]); + }); + + // allowedScopes INTERSECTS the requested scopes instead of rejecting them, so a short list would + // silently strip openid/profile/email/offline_access from every token — no error, no id_token, no + // refresh. Pinned against the full constant. + test("allows the full advertised scope set on the resource, not just the resource scopes", () => { + const { resources } = getMcpOauthProviderOptions(); + + expect(resources?.[0]).toMatchObject({ allowedScopes: [...MCP_OAUTH_SCOPES] }); + }); - expect(validAudiences).toEqual([getMcpResourceUrl()]); + // Boot-time config must never revert an operator's CRUD edit on restart. + test("seeds resources insert-only", () => { + expect(getMcpOauthProviderOptions().resourceSeedMode).toBe("insertOnly"); }); test("advertises only scopes it is willing to grant", () => { diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts index f2bedb351186..645d0f5fd570 100644 --- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts +++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts @@ -17,7 +17,53 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({ advertisedMetadata: { scopes_supported: [...MCP_OAUTH_SCOPES], }, - validAudiences: [getMcpResourceUrl()], + // Better Auth 1.7 replaced the flat `validAudiences` allow-list with persisted resources + // (ENG-2343). The difference is the point of the upgrade: 1.6 stamped a token with whatever the + // client asked for, checked only against this list, so nothing tied the token to what the user + // actually approved (GHSA-p2fr-6hmx-4528). 1.7 binds the grant instead. + // + // `allowedScopes` intersects the requested scopes rather than rejecting them, so it MUST be the + // full MCP_OAUTH_SCOPES set. Narrowing it to the six resource scopes would silently strip openid, + // profile, email and offline_access from every token — killing id_tokens and refresh with no error + // anywhere. Derived from the constant so the two cannot drift. + // + // No `accessTokenTtl` on purpose: leaving it unset keeps expiry driven by `accessTokenExpiresIn` + // and `scopeExpirations` below, preserving the 15-minute write step-up exactly as it works today. + // A per-resource TTL would be min()'d with those and only muddy the derivation. + resources: [ + { + identifier: getMcpResourceUrl(), + name: "Formbricks MCP", + allowedScopes: [...MCP_OAUTH_SCOPES], + }, + ], + // Boot-time config never overwrites a row an operator edited through the CRUD endpoints. This is + // the upstream default; pinned explicitly because a silent policy revert on restart would be very + // hard to attribute. + // + // ⚠ The flip side, for whoever changes a deployment's WEBAPP_URL after install: the resource + // identifier above is derived from it, so a new URL is a NEW resource. insertOnly means boot adds a + // second `oauthResource` row and points `clientRegistrationDefaultResources` at it, while every + // already-registered client keeps its `oauthClientResource` link to the OLD identifier — and with + // `enforcePerClientResources` on, those clients then fail `invalid_target` at the token endpoint, + // after the user has already consented. Nothing self-heals it, because insertOnly is what stops boot + // from rewriting operator-owned rows. + // + // Migrating the URL therefore means repointing the links, not just restarting: update the existing + // `oauthResource.identifier` in place (the FK from `oauthClientResource.resourceId` is + // ON UPDATE CASCADE, so the link rows follow), rather than letting a second row appear. The + // alternative — telling every MCP user to re-register their client — is the thing the ENG-2343 + // backfill exists to avoid. + resourceSeedMode: "insertOnly", + // Mandatory, not optional. `enforcePerClientResources` defaults to true, and with no registration + // resources configured the plugin rejects every explicit resource request — which would break each + // MCP client the moment it registered. + clientRegistrationDefaultResources: [getMcpResourceUrl()], + // `cachedResources` is deliberately NOT set. Its cache is module-scoped with no TTL, invalidated + // only by CRUD writes in the same process, so on multiple replicas disabling a resource would not + // take effect until every pod restarted — defeating `disabled` as a revocation lever. It would + // save one indexed read per /oauth2/token call, which is not the hot path (/api/mcp verifies JWTs + // locally against a cached JWKS and never reads these tables). allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, // Register MCP clients with the full advertised scope set by default so the consent screen offers @@ -51,10 +97,9 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({ introspect: { window: 60, max: 60 }, revoke: { window: 60, max: 30 }, }, - // Discovery is served by our Next.js catch-all at /.well-known/oauth-authorization-server/api/auth; - // Better Auth can't introspect the route, so this acks the (verified-correct) endpoint rather than - // masking a real problem. See PR #8447. - silenceWarnings: { - oauthAuthServerConfig: true, - }, + // `silenceWarnings` was removed in Better Auth 1.7 (ENG-2343). It acknowledged an + // `oauthAuthServerConfig` warning: discovery is served by our Next.js catch-all at + // /.well-known/oauth-authorization-server/api/auth, which Better Auth cannot introspect, so the + // warning was noise about a verified-correct endpoint rather than a real problem (PR #8447). + // Nothing replaces it upstream — if 1.7 still emits that warning it is expected and harmless here. }); diff --git a/apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts b/apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts new file mode 100644 index 000000000000..907e5180bd39 --- /dev/null +++ b/apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; +import { MCP_OAUTH_SCOPES } from "./oauth-urls"; + +/** + * ENG-2343. The data migration seeds the `oauthResource` row for instances that already have MCP clients, + * and it must carry the same `allowedScopes` the plugin would seed on a fresh install. It cannot import + * that list — `packages/database` may not depend on `apps/web`, and the migration is not an exported + * subpath — so it keeps a local copy, and this test is what makes the copy safe. + * + * Not cosmetic. `allowedScopes` **intersects** the requested scopes rather than validating them + * (`resolveResourcePolicy` in `@better-auth/oauth-provider`), and it skips only NULL/undefined. So a + * scope the app advertises but this row omits is silently intersected away, and a request for only that + * scope fails `invalid_scope` at `/authorize`. With `resourceSeedMode: "insertOnly"` the row is never + * repaired at boot, so any divergence is permanent for every upgraded instance. + * + * Read as text rather than imported: crossing the workspace boundary in a type-checked import would fight + * the app's tsconfig, and the value under test is a literal, so parsing it is sufficient. + */ +const MIGRATION_PATH = + "../../packages/database/migration/20260812110001_eng_2343_backfill_oauth_resource_links/migration.ts"; + +const readSeededScopes = (): string[] => { + const source = readFileSync(resolve(process.cwd(), MIGRATION_PATH), "utf8"); + const declaration = /export const MCP_RESOURCE_ALLOWED_SCOPES = \[([^\]]*)\]/.exec(source); + if (!declaration) { + throw new Error( + "MCP_RESOURCE_ALLOWED_SCOPES was not found in the migration — it was renamed or removed, which " + + "means the seeded resource may no longer allow the scopes the app grants." + ); + } + return [...declaration[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); +}; + +describe("the migration's seeded allowedScopes matches the advertised scope set (ENG-2343)", () => { + test("is exactly MCP_OAUTH_SCOPES, in the same order", () => { + expect(readSeededScopes()).toEqual([...MCP_OAUTH_SCOPES]); + }); + + // The failure mode that matters, stated on its own: a scope the app can grant that the resource row + // would intersect away. + test("allows every scope the app can grant", () => { + const allowed = new Set(readSeededScopes()); + + expect(MCP_OAUTH_SCOPES.filter((scope) => !allowed.has(scope))).toEqual([]); + }); +}); diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts index 1ec3088cbda9..68dc8eb2048e 100644 --- a/apps/web/modules/auth/lib/oauth-urls.ts +++ b/apps/web/modules/auth/lib/oauth-urls.ts @@ -53,12 +53,26 @@ export const getMcpOrigin = (): string => new URL(getMcpResourceUrl()).origin; * Built off the issuer for the same reason `jwksUrl` is: Better Auth mounts its OAuth endpoints * under the auth base path, so the issuer is the prefix the plugin itself uses. * - * The assumption is that this equals Better Auth's own `ctx.context.baseURL`, which is what it - * stamps into the audience. That holds while the configured auth URL is a bare origin — Better - * Auth's `withPath` appends `/api/auth` exactly as `getAuthIssuerUrl` does. It does NOT hold if - * `BETTER_AUTH_URL` carries a subpath, because `withPath` returns a URL that already has a path - * unchanged while we still append. Subpath deployments cannot complete a login at all today - * (ENG-606), so this is not a live gap — but it is the thing to fix here when that one is fixed. + * The assumption is that this equals Better Auth's own `ctx.context.baseURL`, which is what it stamps + * into the audience. It holds for both shapes an operator is actually told to configure: + * + * - a bare origin — upstream's `withPath` appends `/api/auth`, exactly as `appendPath` does here; + * - a subpath already ending in `/api/auth` (`https://host/custom-path/api/auth`, which is the literal + * value `docs/self-hosting/configuration/custom-subpath.mdx` prescribes) — `withPath` returns it + * unchanged because `checkHasPath` is true, and `appendPath` returns it unchanged because its + * `basePath.endsWith(normalizedPath)` branch fires. + * + * The one shape where they diverge is a configured URL carrying a path that does NOT end in + * `/api/auth`: `withPath` leaves any non-empty path alone, while `appendPath` would append. Note this + * is narrower than it used to say here — "any subpath breaks it" is wrong, and the documented subpath + * is precisely the case that works. Subpath deployments cannot complete a login at all today (ENG-606), + * so it is still not a live gap. + * + * This matters beyond the audience now: `ssoLegacyRedirectUri` in better-auth-providers.ts builds the + * pinned SSO callback URL from `getAuthIssuerUrl()` (ENG-2343). Because that URL is pinned explicitly, + * Better Auth sends it on both the authorization and token legs regardless of its own `baseURL`, so a + * divergence here cannot desynchronise the handshake — it would only mean the URL names a host the + * operator did not intend, which is a configuration error rather than a protocol one. */ export const getOAuthUserInfoUrl = (): string => `${getAuthIssuerUrl()}/oauth2/userinfo`; diff --git a/apps/web/modules/auth/lib/signup-policy.test.ts b/apps/web/modules/auth/lib/signup-policy.test.ts index 2625728258be..52117eeb8fd3 100644 --- a/apps/web/modules/auth/lib/signup-policy.test.ts +++ b/apps/web/modules/auth/lib/signup-policy.test.ts @@ -78,7 +78,7 @@ describe("signupPolicyBeforeHandler", () => { test("ignores every path other than the credential sign-up route", async () => { closeTheInstance(); - for (const path of ["/sign-in/email", "/reset-password", "/oauth2/callback/openid", "/get-session"]) { + for (const path of ["/sign-in/email", "/reset-password", "/callback/openid", "/get-session"]) { await expect(signupPolicyBeforeHandler({ path } as never)).resolves.toBeUndefined(); } }); diff --git a/apps/web/modules/ee/sso/components/azure-button.tsx b/apps/web/modules/ee/sso/components/azure-button.tsx index 935c5de3a08e..30c511d9384a 100644 --- a/apps/web/modules/ee/sso/components/azure-button.tsx +++ b/apps/web/modules/ee/sso/components/azure-button.tsx @@ -30,8 +30,12 @@ export const AzureButton = ({ } const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source); - await authClient.signIn.oauth2({ - providerId: "azuread", + // Better Auth 1.7 rebuilt genericOAuth onto the built-in social path (ENG-2343), so + // signIn.oauth2({ providerId }) became signIn.social({ provider }). The callback URL is + // NOT affected: better-auth-providers.ts pins `redirectURI` to /api/auth/oauth2/callback/azuread, + // the URL already registered at every customer IdP, and legacy-sso-callback.ts serves it. + await authClient.signIn.social({ + provider: "azuread", callbackURL: returnToUrlWithSource, // OAuth failures redirect here so the login page's existing ?error= UX surfaces them (parity). errorCallbackURL: "/auth/login", diff --git a/apps/web/modules/ee/sso/components/open-id-button.tsx b/apps/web/modules/ee/sso/components/open-id-button.tsx index 618c428ca2c5..ce708dfb8b14 100644 --- a/apps/web/modules/ee/sso/components/open-id-button.tsx +++ b/apps/web/modules/ee/sso/components/open-id-button.tsx @@ -31,8 +31,12 @@ export const OpenIdButton = ({ } const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source); - await authClient.signIn.oauth2({ - providerId: "openid", + // Better Auth 1.7 rebuilt genericOAuth onto the built-in social path (ENG-2343), so + // signIn.oauth2({ providerId }) became signIn.social({ provider }). The callback URL is + // NOT affected: better-auth-providers.ts pins `redirectURI` to /api/auth/oauth2/callback/openid, + // the URL already registered at every customer IdP, and legacy-sso-callback.ts serves it. + await authClient.signIn.social({ + provider: "openid", callbackURL: returnToUrlWithSource, // OAuth failures redirect here so the login page's existing ?error= UX surfaces them (parity). errorCallbackURL: "/auth/login", diff --git a/apps/web/modules/ee/sso/components/saml-button.tsx b/apps/web/modules/ee/sso/components/saml-button.tsx index 51d52f3716d4..0d5b1c972a0e 100644 --- a/apps/web/modules/ee/sso/components/saml-button.tsx +++ b/apps/web/modules/ee/sso/components/saml-button.tsx @@ -36,8 +36,12 @@ export const SamlButton = ({ returnToUrl, lastUsed, source }: Readonly { type: "oauth" as const, provider: "google", providerAccountId: "provider-account-1", + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", scope: "openid email profile", @@ -100,6 +101,10 @@ describe("syncSsoIdentityForUser", () => { id: "account_1", }, data: { + // `issuer` on the token-refresh branch too (ENG-2343): the canonical row may predate the + // backfill window, and 1.7's account lookup filters on `(issuer, accountId)` — so leaving it + // NULL here would keep a recovered link invisible and re-trigger recovery on the next sign-in. + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", scope: "openid email profile", @@ -133,6 +138,7 @@ describe("syncSsoIdentityForUser", () => { type: "oauth", provider: "google", providerAccountId: "provider-account-1", + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", scope: "openid email profile", @@ -216,6 +222,7 @@ describe("syncSsoIdentityForUser", () => { type: "oauth", provider: "google", providerAccountId: "provider-account-1", + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", expires_at: 1234, diff --git a/apps/web/modules/ee/sso/lib/account-linking.ts b/apps/web/modules/ee/sso/lib/account-linking.ts index 274a12b5875e..bac495bf7d8e 100644 --- a/apps/web/modules/ee/sso/lib/account-linking.ts +++ b/apps/web/modules/ee/sso/lib/account-linking.ts @@ -1,7 +1,7 @@ import { prisma } from "@formbricks/database"; import type { IdentityProvider, Prisma } from "@formbricks/database/prisma"; import type { Account } from "@formbricks/types/auth"; -import { OAUTH_ACCOUNT_NOT_LINKED_ERROR } from "@/modules/ee/sso/lib/constants"; +import { OAUTH_ACCOUNT_NOT_LINKED_ERROR, ssoAccountIssuer } from "@/modules/ee/sso/lib/constants"; export const LINKED_SSO_LOOKUP_SELECT = { id: true, @@ -97,7 +97,9 @@ const syncSsoIdentityForUserWithTx = async ({ where: { id: existingCanonicalAccount.id, }, - data: getAccountTokenUpdate(account), + // `issuer` too: the canonical row may predate the ENG-2343 backfill window, and leaving it NULL + // here would keep the recovered link invisible to 1.7's account lookup. + data: { issuer: ssoAccountIssuer(provider), ...getAccountTokenUpdate(account) }, }); } else { await tx.account.update({ @@ -109,6 +111,9 @@ const syncSsoIdentityForUserWithTx = async ({ type: account.type, provider, providerAccountId: account.providerAccountId, + // Same reason as the create branch below: normalising a legacy row without setting `issuer` + // leaves it unmatched by 1.7's account lookup (ENG-2343). + issuer: ssoAccountIssuer(provider), ...getAccountTokenUpdate(account), }, }); @@ -127,6 +132,13 @@ const syncSsoIdentityForUserWithTx = async ({ type: account.type, provider, providerAccountId: account.providerAccountId, + // 1.7 keys the account on `(issuer, accountId)` and `findAccountByKey` filters on `issuer`, so a + // row written without one is invisible to every later sign-in: the user completes + // verify-before-link, gets a session, and is then pushed back through recovery on the NEXT + // sign-in because `NULL !== 'local:oauth:'`. The migration cannot save them either — + // it runs once, before this row exists. Same value as the provider config and the backfill + // (ENG-2343); imported rather than re-spelled so the three cannot drift. + issuer: ssoAccountIssuer(provider), ...getAccountTokenUpdate(account), }, }); diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts index 5e1c948373ac..17b41b53312a 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts @@ -72,7 +72,9 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsSamlSsoEnabled: vi.fn(), })); -const callbackCtx = { path: "/oauth2/callback/:providerId", params: { providerId: "openid" } }; +// Better Auth's INTERNAL endpoint path, which 1.7 serves at `/callback/:id`. Not the public SSO callback +// URL — that stays `/api/auth/oauth2/callback/{providerId}`, pinned (ENG-2343) and mapped onto this one. +const callbackCtx = { path: "/callback/:providerId", params: { providerId: "openid" } }; const provisionDecision = { action: "provision" as const, organizationId: "org-1", @@ -101,7 +103,7 @@ beforeEach(() => { describe("getSsoProviderFromContext", () => { test("reads the provider from a generic-OAuth callback's params", () => { expect( - getSsoProviderFromContext({ path: "/oauth2/callback/:providerId", params: { providerId: "openid" } }) + getSsoProviderFromContext({ path: "/callback/:providerId", params: { providerId: "openid" } }) ).toBe("openid"); }); @@ -110,7 +112,7 @@ describe("getSsoProviderFromContext", () => { }); test("falls back to parsing a resolved callback path", () => { - expect(getSsoProviderFromContext({ path: "/oauth2/callback/azuread", params: {} })).toBe("azuread"); + expect(getSsoProviderFromContext({ path: "/callback/azuread", params: {} })).toBe("azuread"); }); test.each([{ path: "/sign-up/email" }, { path: "/sign-in/email" }, {}, null, undefined])( @@ -379,7 +381,7 @@ describe("ssoDatabaseHooks.account.create.after", () => { }); describe("ssoLicenseGateBefore", () => { - const samlCtx = { path: "/oauth2/callback/:providerId", params: { providerId: "saml" } }; + const samlCtx = { path: "/callback/:providerId", params: { providerId: "saml" } }; test("ignores non-callback requests without checking the license", async () => { await ssoLicenseGateBefore({ path: "/sign-up/email" } as never); @@ -409,7 +411,7 @@ describe("ssoLicenseGateBefore", () => { describe("ssoRecoveryAfter", () => { const collisionLocation = "https://app.test/error?error=account_not_linked"; const makeCtx = (overrides: Record = {}) => ({ - path: "/oauth2/callback/:providerId", + path: "/callback/:providerId", params: { providerId: "openid" }, context: { responseHeaders: new Headers({ location: collisionLocation }) }, redirect: vi.fn((url: string) => new Error(`redirect:${url}`)), @@ -487,7 +489,7 @@ describe("ssoRecoveryAfter", () => { describe("blockedSignupDomainRedirectAfter", () => { const makeCtx = (overrides: Record = {}) => ({ - path: "/oauth2/callback/:providerId", + path: "/callback/:providerId", params: { providerId: "openid" }, context: { responseHeaders: new Headers({ location: "https://app.test/auth/login?error=unable_to_create_user" }), diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts index d57f911f5ebf..6e350d61b795 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts @@ -30,9 +30,10 @@ import { /** * Resolve the SSO provider id from a Better Auth callback endpoint context, else null. * - * Better Auth sets `context.path` to the ROUTE PATTERN — `/oauth2/callback/:providerId` for the - * generic-OAuth plugin, `/callback/:id` for built-in social — and the matched provider on - * `context.params` (`providerId` or `id`). We prefer the parsed param and fall back to parsing a + * Better Auth sets `context.path` to the ROUTE PATTERN — `/callback/:id` for both built-in social + * providers and, since Better Auth 1.7 (ENG-2343), the `genericOAuth` plugin too (it now shares the + * social-provider route instead of its own `/oauth2/callback/:providerId`) — and the matched provider + * on `context.params` (`providerId` or `id`). We prefer the parsed param and fall back to parsing a * resolved path. Returns null for non-callback paths (e.g. `/sign-up/email`) so the hooks below * only act on SSO sign-ups. */ diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts index 665202cffe16..585423be38d6 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { PINNED_SSO_PROVIDER_IDS } from "@/modules/auth/lib/legacy-sso-callback"; // captureSsoIdentity is request-scoped (server-only AsyncLocalStorage); stub it so the mappers run in // isolation and we can assert the identity each provider captures. const { captureSsoIdentity } = vi.hoisted(() => ({ captureSsoIdentity: vi.fn() })); vi.mock("./sso-request-context", () => ({ captureSsoIdentity })); +// The pinned SSO callback URL is built from `getAuthIssuerUrl()`, which reads `@/lib/env` directly rather +// than the constants mocked below — it has to, because that helper encodes Better Auth's own base-URL +// precedence (`BETTER_AUTH_URL ?? NEXTAUTH_URL ?? WEBAPP_URL`). Spread the real env so `@/lib/constants` +// still validates, and pin only the auth URL so the expected callback URL is deterministic. +vi.mock("@/lib/env", async () => { + const actual = await vi.importActual<{ env: Record }>("@/lib/env"); + return { env: { ...actual.env, BETTER_AUTH_URL: "https://app.formbricks.test" } }; +}); + // The module computes ssoSocialProviders / ssoGenericOAuthConfig at IMPORT time from `@/lib/constants`, // so each scenario re-mocks the constants and re-imports. We spread the REAL module so the two hardcoded // SAML literals (SAML_TENANT/SAML_PRODUCT) keep their real values — they are not env-derived toggles and @@ -168,18 +178,149 @@ describe("better-auth SSO providers", () => { "https://login.microsoftonline.com/tenant-123/v2.0/.well-known/openid-configuration" ); expect(azure.scopes).toEqual(["openid", "email", "profile"]); - // ENG-1800: Entra never returns the RFC 9207 response `iss`, so validation must stay off even - // with a fixed tenant — turning it on here is exactly what caused `error=issuer_missing`. - expect(azure.requireIssuerValidation).toBe(false); + // ENG-1800 no longer needs an opt-out: Better Auth 1.7 only compares the RFC 9207 response + // `iss` when the provider actually sends one, and Entra never does, so the check that produced + // `error=issuer_missing` cannot fire. What replaces it as the load-bearing invariant is the + // pinned account issuer below. + expect(azure).not.toHaveProperty("requireIssuerValidation"); + expect(azure.accountIssuer).toBe("local:oauth:azuread"); + }); + + /** + * The account-identity contract with the database migration (ENG-2343). + * + * Better Auth 1.7 keys accounts on (issuer, accountId). Left unpinned, a provider with a + * discoveryUrl adopts the DISCOVERED issuer — tenant-specific, so different on every install and + * impossible to reproduce in a portable backfill. These values must stay byte-identical to what + * migration 20260812110000 writes into Account.issuer, or existing SSO users stop matching at + * sign-in and are pushed into account recovery. + */ + test("pins a portable account issuer on every generic provider", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + + expect(m.ssoGenericOAuthConfig.map((c) => [c.providerId, c.accountIssuer])).toEqual([ + ["azuread", "local:oauth:azuread"], + ["openid", "local:oauth:openid"], + ["saml", "local:oauth:saml"], + ]); }); - test("Azure discovery URL falls back to the 'common' tenant when none is configured", async () => { + /** + * The callback URL is a registered value at every customer IdP, and OAuth requires it to match + * EXACTLY (RFC 6749 §3.1.2.2, no wildcards) — so letting it track Better Auth's routing means every + * self-hoster edits every IdP whenever upstream moves the route. It has already moved twice: the 1.6 + * genericOAuth plugin mounted `/oauth2/callback/:providerId`, and 1.7 rebuilt the plugin onto the + * built-in `/callback/:id`. This pins the v5.2 URL that is already registered everywhere. + * + * The URL alone is not enough — see legacy-sso-callback.ts for the half that serves it, and + * better-auth-redirect-uri-pin.test.ts for the guard that Better Auth still honours the option. + */ + test("pins the v5.2 callback URL on every generic provider", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + + expect(m.ssoGenericOAuthConfig.map((c) => [c.providerId, c.redirectURI])).toEqual([ + ["azuread", "https://app.formbricks.test/api/auth/oauth2/callback/azuread"], + ["openid", "https://app.formbricks.test/api/auth/oauth2/callback/openid"], + ["saml", "https://app.formbricks.test/api/auth/oauth2/callback/saml"], + ]); + }); + + /** + * Drift guard for the two halves of the pin. `legacy-sso-callback.ts` keeps its own id literal + * because it must work on an unlicensed instance, where this config list is empty — so nothing but + * this assertion stops the two from diverging. A provider pinned here but missing there advertises a + * URL no route serves: a 404 on every sign-in with that provider. + */ + test("every pinned provider is one the legacy callback route serves", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + + const pinned = m.ssoGenericOAuthConfig + .filter((c) => c.redirectURI?.includes("/api/auth/oauth2/callback/")) + .map((c) => c.providerId); + + expect(pinned).toEqual([...PINNED_SSO_PROVIDER_IDS]); + }); + + /** + * The multi-tenant case must NOT use discovery (ENG-2343). Microsoft's `common` discovery document + * advertises `issuer: "https://login.microsoftonline.com/{tenantid}/v2.0"` — a documented template, + * verified against the live endpoint — and Better Auth 1.7 compares `iss` for literal equality + * whenever discovery yields both `issuer` and `jwks_uri`. Every real id_token carries the tenant + * GUID, so discovery here would reject every Azure sign-in. Explicit endpoints build no + * `idTokenConfig` at all, which restores the 1.6 UserInfo path. + */ + /** + * Identity derivation must not depend on whether discovery ran (ENG-2343). Better Auth's default is + * `isOidc ? profile.sub : profile.id`, and `isOidc` is set only inside the discovery branch — so the + * Azure `common` path and the SAML bridge, which both configure endpoints explicitly, would fall to + * `profile.id`. Microsoft Graph `/oidc/userinfo` returns only `sub`, which yielded an empty subject + * and failed the callback with `unable_to_get_user_info`. BoxyHQ genuinely returns `id`, so the two + * differ and both have to be pinned rather than inferred. + */ + test.each([ + ["azuread", { sub: "az-sub", id: "wrong" }, "az-sub"], + ["openid", { sub: "oidc-sub", id: "wrong" }, "oidc-sub"], + ["saml", { id: "saml-id", sub: "wrong" }, "saml-id"], + ])( + "%s derives its account subject from the field that provider actually sends", + async (providerId, profile, expected) => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + const provider = m.ssoGenericOAuthConfig.find((c) => c.providerId === providerId); + + expect(provider?.accountSubject).toBeDefined(); + expect(provider?.accountSubject?.({ profile } as never)).toBe(expected); + } + ); + + test("Azure uses explicit endpoints, not discovery, when no tenant is configured", async () => { const m = await loadProviders({ ENTERPRISE_LICENSE_KEY: "lic", AZURE_OAUTH_ENABLED: true }); const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); + + expect(azure?.discoveryUrl).toBeUndefined(); + expect(azure?.authorizationUrl).toBe("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"); + expect(azure?.tokenUrl).toBe("https://login.microsoftonline.com/common/oauth2/v2.0/token"); + expect(azure?.userInfoUrl).toBe("https://graph.microsoft.com/oidc/userinfo"); + expect(azure?.accountIssuer).toBe("local:oauth:azuread"); + }); + + /** + * The single-tenant case keeps discovery, so the id_token IS verified against a concrete issuer — + * strictly stronger than 1.6. This pairing is the whole point of the split: no self-hoster has to + * change anything, and those who can have the stronger check get it. + */ + test("Azure uses discovery when a concrete tenant is configured", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + AZUREAD_TENANT_ID: "00000000-1111-2222-3333-444444444444", + }); + const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); + expect(azure?.discoveryUrl).toBe( - "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" + "https://login.microsoftonline.com/00000000-1111-2222-3333-444444444444/v2.0/.well-known/openid-configuration" ); - expect(azure?.requireIssuerValidation).toBe(false); + expect(azure?.authorizationUrl).toBeUndefined(); + expect(azure?.tokenUrl).toBeUndefined(); }); test("Azure mapProfileToUser resolves the display name through its fallback chain", async () => { @@ -220,7 +361,8 @@ describe("better-auth SSO providers", () => { clientId: "oidc-id", clientSecret: "oidc-secret", pkce: true, - requireIssuerValidation: true, + // Issuer validation is automatic in 1.7 for providers that return `iss`, so the flag is gone. + accountIssuer: "local:oauth:openid", }); expect(oidc.discoveryUrl).toBe("https://idp.test/.well-known/openid-configuration"); expect( diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.ts index d650fd0d26e9..b66467df4477 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.ts @@ -1,6 +1,6 @@ import "server-only"; import type { BetterAuthOptions } from "better-auth"; -import type { GenericOAuthConfig } from "better-auth/plugins"; +import type { GenericOAuthConfig, GenericOAuthUserInfo } from "better-auth/plugins"; import { AZUREAD_CLIENT_ID, AZUREAD_CLIENT_SECRET, @@ -22,6 +22,8 @@ import { SAML_TENANT, WEBAPP_URL, } from "@/lib/constants"; +import { getAuthIssuerUrl } from "@/modules/auth/lib/oauth-urls"; +import { ssoAccountIssuer } from "./constants"; import { captureSsoIdentity } from "./sso-request-context"; // Better Auth's per-provider profile types, extracted so the social mappers below aren't implicitly @@ -38,18 +40,20 @@ type GoogleProfile = Parameters["mapProfileTo /** * Better Auth SSO providers (ENG-1054), mirroring the NextAuth set in `./providers.ts`. Gated behind * `ENTERPRISE_LICENSE_KEY` (parity with the `getSSOProviders()` gate) and each provider's configured - * credentials. Google/GitHub use Better Auth's built-in social providers; Azure/OIDC/SAML use the - * generic-OAuth plugin (Azure keeps providerId "azuread" so existing `account.provider` rows need no - * remap — design doc D6). + * credentials. Google/GitHub use Better Auth's built-in social providers; Azure/OIDC/SAML register + * through the `genericOAuth` plugin (Azure keeps providerId "azuread" so existing `account.provider` + * rows need no remap — design doc D6). * * IMPORTANT — these objects only REGISTER providers. The hardened account linking / verify-before-link * (SSO recovery) + org-provisioning flow (design doc D7) is re-expressed via Better Auth hooks * SEPARATELY (not here); `account.accountLinking.enabled` is false so nothing auto-links. That hooks * work is the security-sensitive part of Phase 5 and is pending review. * - * ⚠ The generic-OAuth callback path is `/api/auth/oauth2/callback/{providerId}` (differs from - * NextAuth's `/api/auth/callback/{provider}`) — at cutover, the OIDC IdP redirect URIs and the BoxyHQ - * Jackson connection `redirect_uri` must be re-registered to match. + * ⚠ Callback path (ENG-2343): PINNED, and deliberately not the version default. Better Auth has moved + * this path twice with no choice of ours — 1.6's `genericOAuth` plugin mounted its own + * `/oauth2/callback/:providerId` route, and 1.7 rebuilt that plugin onto the built-in `/callback/:id` + * one. Tracking the default makes every self-hoster re-register a redirect URI on each such upstream + * change, so `redirectURI` below holds the v5.2 URL their IdPs already have. See ssoLegacyRedirectUri. */ export const ssoSocialProviders = ENTERPRISE_LICENSE_KEY ? { @@ -61,7 +65,7 @@ export const ssoSocialProviders = ENTERPRISE_LICENSE_KEY // Capture the resolved identity for verify-before-link recovery (design doc §13). // ⚠ providerAccountId must equal Better Auth's account.accountId — validate at cutover. mapProfileToUser: (profile: GithubProfile) => { - captureSsoIdentity({ email: profile.email, providerAccountId: String(profile.id) }); + captureSsoIdentity({ email: profile.email, providerAccountId: toAccountSubject(profile.id) }); return { email: profile.email }; }, }, @@ -82,6 +86,117 @@ export const ssoSocialProviders = ENTERPRISE_LICENSE_KEY } : {}; +/** + * Coerce a provider subject to a string WITHOUT inventing one. + * + * Better Auth 1.7 types `sub`/`id` as `string | number`, so a bare `String(...)` is tempting — but it + * turns a missing subject into the literal "undefined", which is truthy. `captureSsoIdentity` + * deliberately drops an identity whose providerAccountId is falsy, precisely so a provider that omits + * it cannot drive account recovery and link the wrong account. Passing "undefined" would sail past + * that guard. + */ +const toAccountSubject = (subject: string | number | null | undefined): string | undefined => + subject === null || subject === undefined ? undefined : String(subject); + +/** + * The SSO callback URL every customer IdP has had registered since v5.2, pinned so it stops tracking + * Better Auth's routing (ENG-2343). + * + * `redirectURI` wins over the route-derived value in both places that must agree — the authorization + * request (`@better-auth/core/.../create-authorization-url.mjs`) and the token exchange + * (`.../validate-authorization-code.mjs`), each `options.redirectURI || redirectURI` — so the IdP never + * sees a `redirect_uri` mismatch between the two legs. The option is not new: 1.6 honoured it with the + * same precedence, so pinning is not a 1.7 affordance we might lose on the next minor. + * + * Pinning the URL is only half the job, because `redirectURI` does NOT move the route Better Auth mounts + * its handler on. `apps/web/app/api/auth/[...all]/route.ts` serves this legacy path by mapping it onto + * the path the installed version actually handles — that half is ours and cannot be removed upstream. + * This half rests on an upstream option, so better-auth-redirect-uri-pin.test.ts asserts the + * `redirect_uri` Better Auth really emits and fails the build if it is ever ignored. + * + * Built off `getAuthIssuerUrl()` rather than `WEBAPP_URL`, deliberately: this URL is where the identity + * provider delivers the authorization code, so it must name the same origin Better Auth itself considers + * its base — `env.BETTER_AUTH_URL ?? env.NEXTAUTH_URL` (auth.ts), with WEBAPP_URL only as the last + * fallback, which is exactly the precedence `getAuthIssuerUrl` encodes. Deriving it from WEBAPP_URL alone + * would let the two diverge: the code would arrive at a host whose signed state cookie was never set, so + * sign-in fails closed with a state mismatch. `appendPath` also handles the documented subpath shape where + * the configured auth URL already ends in `/api/auth` (see ENG-606). + */ +const ssoLegacyRedirectUri = (providerId: string): string => + `${getAuthIssuerUrl()}/oauth2/callback/${providerId}`; + +/** OIDC display name: `name`, else given+family, else `preferred_username`. */ +const toDisplayName = (profile: GenericOAuthUserInfo): string | undefined => { + const parts = [profile.given_name, profile.family_name].filter(Boolean).join(" "); + const name = profile.name || parts || profile.preferred_username; + return typeof name === "string" && name.length > 0 ? name : undefined; +}; + +/** BoxyHQ userinfo display name: `name`, else firstName + lastName. */ +const toSamlDisplayName = (profile: GenericOAuthUserInfo): string | undefined => { + const parts = [profile.firstName, profile.lastName].filter(Boolean).join(" "); + const name = profile.name || parts; + return typeof name === "string" && name.length > 0 ? name : undefined; +}; + +/** + * Provider account subject, pinned per provider rather than left to Better Auth's default (ENG-2343). + * + * The default is `isOidc ? profile.sub ?? "" : profile.id ?? ""` (`generic-oauth/index.mjs:138`), and + * `isOidc` is only ever set inside the discovery branch (`:103`). So it silently depends on whether + * discovery ran: our Azure `common` path and the SAML bridge both configure endpoints explicitly, which + * leaves `isOidc` false and resolves `profile.id` — correct for BoxyHQ, which returns `id`, and WRONG for + * Microsoft Graph `/oidc/userinfo`, which returns only `sub`. That yields an empty subject and the + * callback fails `OAUTH_ACCOUNT_SUBJECT_INVALID` → `error=unable_to_get_user_info`. + * + * Pinning it on all three makes identity derivation ours and independent of a discovery field, which + * also stops the openid provider silently changing subject source across upgrades. An absent subject + * still fails closed: Better Auth rejects an empty accountId rather than inventing one. + */ +const ssoAccountSubject = + (field: "sub" | "id") => + ({ profile }: { profile: GenericOAuthUserInfo }): string | number => + profile[field] ?? ""; + +/** + * Azure endpoint configuration, split on whether a concrete tenant is configured (ENG-2343). + * + * Better Auth 1.7 verifies the id_token whenever discovery yields both `jwks_uri` and `issuer`, and it + * compares `iss` for **literal** equality. Microsoft's multi-tenant (`common`) discovery document + * advertises `issuer: "https://login.microsoftonline.com/{tenantid}/v2.0"` — a documented TEMPLATE, not + * a value. Microsoft's own guidance is to substitute the token's `tid` and validate that against the + * tenants you accept; a literal comparison is guaranteed to fail, because every real id_token carries + * the tenant GUID. So with `AZUREAD_TENANT_ID` unset (our documented default) 1.7 would reject every + * Azure sign-in. 1.6 never had this problem: its genericOAuth read identity from UserInfo and never + * parsed the id_token at all. + * + * Rather than make `AZUREAD_TENANT_ID` mandatory — which would demand action from every self-hoster who + * has not set it, and drop support for genuinely multi-tenant app registrations, which have no single + * issuer by construction — the tenant decides the mechanism: + * + * - **Concrete tenant**: keep `discoveryUrl`. The discovered issuer is a real value, so the id_token is + * fully verified. Strictly stronger than 1.6. + * - **`common`**: configure the endpoints explicitly and skip discovery, so no `idTokenConfig` is built + * (`generic-oauth/index.mjs` only constructs it inside the discovery branch) and identity comes from + * UserInfo — the 1.6 behaviour, over a client-authenticated back-channel call to Microsoft. Note this + * is not where the code flow's security lives: that is `state` + PKCE and the authenticated code + * exchange, and RFC 9207 mix-up defence still applies via `iss` on the authorization response when a + * provider sends one. + * + * Deliberately NOT setting `requireIdTokenVerification`: on the `common` path it would throw at init and + * take Azure sign-in down, which is the outcome this split exists to avoid. + */ +const azureTenant = AZUREAD_TENANT_ID || "common"; +const azureEndpoints = AZUREAD_TENANT_ID + ? { + discoveryUrl: `https://login.microsoftonline.com/${azureTenant}/v2.0/.well-known/openid-configuration`, + } + : { + authorizationUrl: `https://login.microsoftonline.com/${azureTenant}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${azureTenant}/oauth2/v2.0/token`, + userInfoUrl: "https://graph.microsoft.com/oidc/userinfo", + }; + export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KEY ? [ ...(AZURE_OAUTH_ENABLED @@ -90,28 +205,29 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE providerId: "azuread", clientId: AZUREAD_CLIENT_ID ?? "", clientSecret: AZUREAD_CLIENT_SECRET ?? "", - discoveryUrl: `https://login.microsoftonline.com/${AZUREAD_TENANT_ID || "common"}/v2.0/.well-known/openid-configuration`, + ...azureEndpoints, scopes: ["openid", "email", "profile"], + // Redundant since 1.7 defaults it to true, kept explicit: this is a security control, + // and an explicit value survives a future default flip. pkce: true, - // Must stay false for Azure. Better Auth's issuer check reads the RFC 9207 - // authorization-RESPONSE `iss` query parameter, and Microsoft Entra does not implement - // RFC 9207 — its v2.0 metadata omits `authorization_response_iss_parameter_supported` - // and it never returns that param. So enabling this can only ever fail with - // `error=issuer_missing` (ENG-1800); it can never pass, regardless of tenant. The - // param's purpose (disambiguating which AS responded, to defend against mix-up) is - // already covered here structurally: the per-provider callback path pins the token - // endpoint to Azure's own, and PKCE (above) + state validation bind the exchange. OIDC - // (below) keeps the check on because a spec-compliant provider does return `iss`. - requireIssuerValidation: false, + // `requireIssuerValidation` is gone in 1.7 (ENG-2343) and this no longer needs an + // opt-out. ENG-1800 was that Better Auth rejected a MISSING RFC 9207 `iss` response + // parameter, which Microsoft Entra never sends — so the check could only ever fail. + // 1.7 only compares `iss` when the provider actually returns one + // (`if (iss && provider.issuer && iss !== provider.issuer)`), so Entra short-circuits + // and the mix-up defence still applies to providers that do implement RFC 9207. + accountIssuer: ssoAccountIssuer("azuread"), + accountSubject: ssoAccountSubject("sub"), + redirectURI: ssoLegacyRedirectUri("azuread"), mapProfileToUser: (profile) => { // Capture for verify-before-link recovery; name parity with the OIDC mapping. - captureSsoIdentity({ email: profile.email, providerAccountId: profile.sub }); + captureSsoIdentity({ + email: profile.email, + providerAccountId: toAccountSubject(profile.sub), + }); return { email: profile.email, - name: - profile.name || - [profile.given_name, profile.family_name].filter(Boolean).join(" ") || - profile.preferred_username, + name: toDisplayName(profile), }; }, } satisfies GenericOAuthConfig, @@ -125,17 +241,23 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE clientSecret: OIDC_CLIENT_SECRET ?? "", discoveryUrl: `${OIDC_ISSUER}/.well-known/openid-configuration`, scopes: ["openid", "email", "profile"], + // Redundant since 1.7 defaults it to true, kept explicit (see azuread above). pkce: true, - requireIssuerValidation: true, // RFC 9207 mix-up defense (design doc §10.3) + // `requireIssuerValidation: true` (RFC 9207 mix-up defence, design doc §10.3) is gone + // in 1.7 — the comparison is now automatic whenever the provider returns `iss`, so the + // defence is kept without the flag. + accountIssuer: ssoAccountIssuer("openid"), + accountSubject: ssoAccountSubject("sub"), + redirectURI: ssoLegacyRedirectUri("openid"), mapProfileToUser: (profile) => { - captureSsoIdentity({ email: profile.email, providerAccountId: profile.sub }); + captureSsoIdentity({ + email: profile.email, + providerAccountId: toAccountSubject(profile.sub), + }); return { email: profile.email, // Parity with provisionNewSsoUser (OIDC): name → given+family → preferred_username. - name: - profile.name || - [profile.given_name, profile.family_name].filter(Boolean).join(" ") || - profile.preferred_username, + name: toDisplayName(profile), }; }, } satisfies GenericOAuthConfig, @@ -152,15 +274,20 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE tokenUrl: `${WEBAPP_URL}/api/auth/saml/token`, userInfoUrl: `${WEBAPP_URL}/api/auth/saml/userinfo`, scopes: [], + // Redundant since 1.7 defaults it to true, kept explicit (see azuread above). pkce: true, + // Already a plain string map, which is all 1.7 accepts here. authorizationUrlParams: { provider: "saml", tenant: SAML_TENANT, product: SAML_PRODUCT }, + accountIssuer: ssoAccountIssuer("saml"), + accountSubject: ssoAccountSubject("id"), + redirectURI: ssoLegacyRedirectUri("saml"), mapProfileToUser: (profile) => { // ⚠ BoxyHQ's userinfo id — validate it matches Better Auth's account.accountId at cutover. - captureSsoIdentity({ email: profile.email, providerAccountId: String(profile.id) }); + captureSsoIdentity({ email: profile.email, providerAccountId: toAccountSubject(profile.id) }); return { email: profile.email, // Parity with provisionNewSsoUser (SAML): name → firstName + lastName. - name: profile.name || [profile.firstName, profile.lastName].filter(Boolean).join(" "), + name: toSamlDisplayName(profile), }; }, } satisfies GenericOAuthConfig, diff --git a/apps/web/modules/ee/sso/lib/constants.ts b/apps/web/modules/ee/sso/lib/constants.ts index d060f5aa339c..c318ec27ca4f 100644 --- a/apps/web/modules/ee/sso/lib/constants.ts +++ b/apps/web/modules/ee/sso/lib/constants.ts @@ -1,2 +1,21 @@ export const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked"; export const SSO_RECOVERY_COMPLETION_PATH = "/api/auth/sso/recovery/complete"; + +/** + * The synthetic `Account.issuer` for our generic-OAuth providers (ENG-2343). + * + * Lives here, in a dependency-free module, because THREE places must produce byte-identical values and + * a literal repeated three times is a silent-divergence bug waiting to happen: + * + * 1. `better-auth-providers.ts` — `accountIssuer`, what Better Auth writes and looks up. + * 2. `account-linking.ts` — the rows SSO recovery writes itself. + * 3. `migration/20260812110000_…` — the backfill, as a SQL literal (`'local:oauth:' || "provider"`), + * which cannot call TypeScript and is therefore the copy this one has to match. + * + * Deliberately NOT `createOAuthAccountIssuer` from `@better-auth/core/db`, even though it is public and + * currently identical: because `accountIssuer` is set explicitly, Better Auth stores and looks up + * whatever we hand it, so upstream's format never enters the picture — while the SQL literal in (3) + * cannot follow an upstream change. Tracking upstream would drift us away from rows already written. + */ +export const ssoAccountIssuer = (providerId: string): string => + `local:oauth:${encodeURIComponent(providerId)}`; diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts index 74ec14116c17..015acd69ca25 100644 --- a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts @@ -102,6 +102,19 @@ vi.mock("@formbricks/database", () => ({ updateMany: mockWorkflowRunLogUpdateMany, findFirst: mockWorkflowRunLogFindFirst, }, + // Better Auth 1.7's oauthProvider seeds its resources at plugin init (ENG-2343), and this module's + // import graph reaches auth.ts. Seeding against a mock that does not declare `oauthResource` throws + // an unhandled BetterAuthError which fails the run even though every test passes — same stub as the + // unit sibling in process-workflow-run-job.test.ts. + oauthResource: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + upsert: vi.fn().mockImplementation((args: { create: unknown }) => Promise.resolve(args.create)), + }, }, })); @@ -112,14 +125,20 @@ vi.mock("@/lib/organization/service", () => ({ })); vi.mock("@/lib/workspace/service", () => ({ getWorkspaceMemberEmails: mockGetWorkspaceMemberEmails })); -vi.mock("@formbricks/logger", () => ({ - logger: { +vi.mock("@formbricks/logger", () => { + const mockLogger = { debug: vi.fn(), error: mockLoggerError, info: mockLoggerInfo, warn: mockLoggerWarn, - }, -})); + // Better Auth 1.7 warns during init() — through our betterAuthLogger, which routes via + // logger.withContext (ENG-2343). `silenceWarnings` used to suppress that warning and was removed + // upstream, so the call is now unavoidable: mirror the real logger's child-logger shape or it + // crashes as an unhandled rejection. + withContext: vi.fn(() => mockLogger), + }; + return { logger: mockLogger }; +}); // --------------------------------------------------------------------------- // Fixtures — a real survey block/element + a completing response with answers, diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts index 31541fcdab19..0b9ce2353900 100644 --- a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts @@ -49,6 +49,18 @@ vi.mock("@formbricks/database", () => ({ updateMany: mockWorkflowRunLogUpdateMany, findFirst: mockWorkflowRunLogFindFirst, }, + // Better Auth 1.7's oauthProvider plugin seeds resources at boot (ENG-2343); this module's import + // graph reaches auth.ts, and a boot-time seed against a model this mock doesn't declare throws an + // unhandled BetterAuthError. + oauthResource: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + upsert: vi.fn().mockImplementation((args: { create: unknown }) => Promise.resolve(args.create)), + }, }, })); @@ -96,14 +108,19 @@ vi.mock("@/lib/workspace/service", () => ({ getWorkspaceMemberEmails: mockGetWorkspaceMemberEmails, })); -vi.mock("@formbricks/logger", () => ({ - logger: { +vi.mock("@formbricks/logger", () => { + const mockLogger = { debug: vi.fn(), error: mockLoggerError, info: mockLoggerInfo, warn: mockLoggerWarn, - }, -})); + // Better Auth 1.7 (ENG-2343) warns during init() — via our betterAuthLogger, which calls + // logger.withContext(...) — for the oauthAuthServerConfig discovery warning silenceWarnings used + // to suppress. Mirror the real logger's child-logger shape so that doesn't crash as unhandled. + withContext: vi.fn(() => mockLogger), + }; + return { logger: mockLogger }; +}); const triggerPayload = { type: "response.completed" as const, diff --git a/apps/web/modules/mcp/auth.test.ts b/apps/web/modules/mcp/auth.test.ts index 671adb4d3358..7c9010928669 100644 --- a/apps/web/modules/mcp/auth.test.ts +++ b/apps/web/modules/mcp/auth.test.ts @@ -12,15 +12,15 @@ import { handleAuthenticatedMcpRequest, } from "./auth"; -const { verifyAccessTokenMock, userFindUniqueMock } = vi.hoisted(() => ({ - verifyAccessTokenMock: vi.fn(), +const { verifyBearerTokenMock, userFindUniqueMock } = vi.hoisted(() => ({ + verifyBearerTokenMock: vi.fn(), userFindUniqueMock: vi.fn(), })); vi.mock("@better-auth/oauth-provider/resource-client", () => ({ oauthProviderResourceClient: vi.fn(() => ({ getActions: () => ({ - verifyAccessToken: verifyAccessTokenMock, + verifyBearerToken: verifyBearerTokenMock, }), })), })); @@ -30,6 +30,19 @@ vi.mock("@formbricks/database", () => ({ user: { findUnique: userFindUniqueMock, }, + // This file's mock replaces the global one in vitestSetup, so it needs its own no-op + // `oauthResource`: Better Auth 1.7 seeds resources when the oauthProvider plugin initialises, + // which importing ./auth triggers, and without it the adapter throws an unhandled + // `Model oauthResource does not exist in the database` alongside a passing suite. + oauthResource: { + findMany: () => Promise.resolve([]), + findFirst: () => Promise.resolve(null), + findUnique: () => Promise.resolve(null), + create: (args: { data: unknown }) => Promise.resolve(args.data), + createMany: () => Promise.resolve({ count: 0 }), + update: (args: { data: unknown }) => Promise.resolve(args.data), + upsert: (args: { create: unknown }) => Promise.resolve(args.create), + }, }, })); @@ -114,7 +127,7 @@ function createRequest(url = "http://localhost/api/mcp", headers: Record { beforeEach(() => { vi.clearAllMocks(); - verifyAccessTokenMock.mockReset(); + verifyBearerTokenMock.mockReset(); userFindUniqueMock.mockResolvedValue({ isActive: true }); vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true }); vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); @@ -288,7 +301,7 @@ describe("authenticateMcpRequest", () => { }); test("authenticates OAuth bearer tokens and rate limits by user and client", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", email: "user@example.com", @@ -319,10 +332,13 @@ describe("authenticateMcpRequest", () => { }); } expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); - expect(verifyAccessTokenMock).toHaveBeenCalledWith("oauth_access_token", { + // Exact equality on purpose: this is the canary for the verify options drifting. `typ` pins the + // RFC 9068 access-token type, enforceable from Better Auth 1.7 (1.6 emitted no typ header). + expect(verifyBearerTokenMock).toHaveBeenCalledWith("oauth_access_token", { verifyOptions: { audience: "https://app.example.com/api/mcp", issuer: "https://app.example.com/api/auth", + typ: "at+jwt", }, jwksUrl: "https://app.example.com/api/auth/jwks", }); @@ -337,7 +353,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects OAuth bearer tokens for inactive users", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", @@ -376,7 +392,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects OAuth bearer tokens holding no MCP resource scope at all", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", client_id: "client_2", @@ -407,7 +423,7 @@ describe("authenticateMcpRequest", () => { // The inverse of the challenge fix: offline_access is advertised so clients can obtain a refresh // token, but it grants no resource access, so it must never satisfy the baseline gate on its own. test("rejects an OAuth bearer token scoped only to offline_access", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", client_id: "client_2", @@ -432,7 +448,7 @@ describe("authenticateMcpRequest", () => { test.each([["feedbackRecords:read"], ["surveys:write"]])( "authenticates an OAuth token scoped only to %s", async (scope) => { - verifyAccessTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", scope }); + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", scope }); const result = await authenticateMcpRequest( createRequest("http://localhost/api/mcp", { @@ -452,7 +468,7 @@ describe("authenticateMcpRequest", () => { // provider drops `verifyOptions.audience` from its own verification, at which point this check is // the only thing standing between a foreign-audience token and the MCP tools. test("rejects an OAuth token whose audience omits the MCP resource, independently of jose", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: "https://other.example.com/api", sub: "user_1", azp: "client_1", @@ -474,7 +490,7 @@ describe("authenticateMcpRequest", () => { }); test("returns 429 when audience-rejected tokens exceed the unauthenticated MCP rate limit", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: [MCP_AUDIENCE, "https://other.example.com/api"], sub: "user_1", azp: "client_1", @@ -496,7 +512,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects OAuth bearer tokens without a user subject", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, azp: "client_1", scope: "surveys:read", @@ -519,7 +535,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects invalid OAuth bearer tokens with an OAuth challenge", async () => { - verifyAccessTokenMock.mockRejectedValue(new Error("Invalid token")); + verifyBearerTokenMock.mockRejectedValue(new Error("Invalid token")); const result = await authenticateMcpRequest( createRequest("http://localhost/api/mcp", { @@ -547,7 +563,7 @@ describe("authenticateMcpRequest", () => { }); test("returns 429 when OAuth requests are rate limited", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", @@ -620,7 +636,7 @@ describe("MCP OAuth access token audience binding", () => { vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); keyPair = await generateKeyPair("ES256"); - verifyAccessTokenMock.mockImplementation( + verifyBearerTokenMock.mockImplementation( async (token: string, opts: { verifyOptions: { audience: string; issuer: string } }) => (await jwtVerify(token, keyPair.publicKey, opts.verifyOptions)).payload ); @@ -628,7 +644,9 @@ describe("MCP OAuth access token audience binding", () => { async function signAccessToken(aud: string | string[] | undefined): Promise { const token = new SignJWT({ scope: "surveys:read", azp: "client_1" }) - .setProtectedHeader({ alg: "ES256" }) + // `typ: at+jwt` is what Better Auth 1.7 stamps on an access token (RFC 9068 §2.1), and the + // resource server now requires it — so the fixtures have to carry it to stay realistic. + .setProtectedHeader({ alg: "ES256", typ: "at+jwt" }) .setIssuer(ISSUER) .setSubject("user_1") .setIssuedAt() diff --git a/apps/web/modules/mcp/auth.ts b/apps/web/modules/mcp/auth.ts index 75386128f8b0..37094a42dc93 100644 --- a/apps/web/modules/mcp/auth.ts +++ b/apps/web/modules/mcp/auth.ts @@ -39,6 +39,12 @@ const QUERY_CREDENTIAL_PARAMS = new Set([ "authorization", ]); +/** + * RFC 9068 §2.1 media type for a JWT access token. Better Auth stamps it into the JWS header from + * 1.7 onwards; 1.6 emitted no `typ` at all. + */ +const JWT_ACCESS_TOKEN_TYPE = "at+jwt"; + const oauthResourceClient = oauthProviderResourceClient(auth); export type TMcpAuthInfo = AuthInfo & { @@ -395,10 +401,31 @@ async function authenticateMcpOAuthBearer( let payload: JWTPayload; try { - payload = await oauthResourceClient.getActions().verifyAccessToken(token, { + // Renamed from `verifyAccessToken` in Better Auth 1.7 (ENG-2343). `hasAcceptedMcpAudience` below is + // kept regardless of what upstream does with `verifyOptions.audience`: an earlier version of this + // comment asserted that 1.7 stops passing it into its own `jwtVerify`, which could not be + // substantiated — `verifyBearerToken` is re-exported through `better-auth/oauth2` and its body is not + // readable in the published dist. So the reason to keep our own check is not a claim about upstream: + // it is that "every `aud` resolves to a registered resource" and "this token is for ME" are different + // questions, and only the second is the one a resource server must answer. Ours answers it. + payload = await oauthResourceClient.getActions().verifyBearerToken(token, { verifyOptions: { audience: getMcpResourceUrl(), issuer: getAuthIssuerUrl(), + // RFC 9068 §4: an access token must be typed `at+jwt`, and a resource server should refuse + // one that is not. Enforceable only from 1.7 — 1.6 issued no `typ` header at all, so + // requiring it before the upgrade would have rejected every token in circulation. + // + // Kept strict through the rolling deploy, deliberately. A 1.6-minted token (no `typ`) hitting a + // 1.7 pod is rejected here — but this check is on the RESOURCE SERVER only, not on the refresh + // path, and `20260812110001_eng_2343_backfill_oauth_resource_links` backfills + // `oauthRefreshToken.resources` precisely so existing refresh tokens keep working. So a client + // takes one 401, refreshes against the 1.7 authorization server, and retries with a typed token: + // self-healing in a single round trip, which is the 401 handling every MCP client already + // implements. Relaxing this to "absent is fine" would weaken a cross-JWT-confusion defence + // permanently to smooth a window that closes on its own — the wrong trade in the PR whose whole + // purpose is binding token audiences. + typ: JWT_ACCESS_TOKEN_TYPE, }, jwksUrl: `${getAuthIssuerUrl()}/jwks`, }); diff --git a/apps/web/package.json b/apps/web/package.json index d85b38cc8240..d20cb5c79d1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,8 +24,8 @@ "i18n:generate": "dotenv -e ../../.env -- npx lingo.dev@latest run && dotenv -e ../../.env -- npx lingo.dev@latest lockfile --force" }, "dependencies": { - "@better-auth/core": "1.6.23", - "@better-auth/oauth-provider": "1.6.23", + "@better-auth/core": "1.7.0", + "@better-auth/oauth-provider": "1.7.0", "@better-auth/utils": "0.4.2", "@boxyhq/saml-jackson": "26.2.0", "@cubejs-client/core": "1.6.6", @@ -104,7 +104,7 @@ "@xyflow/react": "12.9.3", "ai": "catalog:", "bcryptjs": "catalog:", - "better-auth": "1.6.23", + "better-auth": "1.7.0", "boring-avatars": "2.0.4", "class-variance-authority": "catalog:", "clsx": "catalog:", diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index a5f64b55c697..e96d904ecb1e 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -1,3 +1,4 @@ +import { createLocalAccountIssuer } from "@better-auth/core/db"; import bcrypt from "bcryptjs"; import { Page } from "playwright"; import { TestInfo } from "playwright/test"; @@ -103,12 +104,21 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixtu // not `user.password` — so a Prisma-seeded user needs an explicit credential account for // POST /api/auth/sign-in/email (used by `login` above) to succeed (ENG-1054). The bcrypt hash // of `uname` is the same secret `login` sends as the plaintext password. + // + // Better Auth 1.7 keys accounts on (issuer, accountId) — sign-in's findCredentialAccount filters + // on `issuer === createLocalAccountIssuer("credential")` (node_modules/better-auth/dist/api/routes/ + // sign-in.mjs), same as the Account.issuer backfill in the ENG-2343 migration. Leaving `issuer` + // unset here means every fixture-created user has issuer=NULL, so that filter never matches: + // sign-in silently returns INVALID_EMAIL_OR_PASSWORD (no session, no cookie) for a correct + // password, and every helper that expects a post-login redirect (loginAndGetApiKey et al.) times + // out waiting for a navigation that never happens. await prisma.account.create({ data: { userId: user.id, type: "credential", provider: "credential", providerAccountId: user.id, + issuer: createLocalAccountIssuer("credential"), password: hashedPassword, }, }); diff --git a/apps/web/vitestSetup.ts b/apps/web/vitestSetup.ts index 965bdc138e9c..a4a3deef6c3c 100644 --- a/apps/web/vitestSetup.ts +++ b/apps/web/vitestSetup.ts @@ -92,6 +92,22 @@ vi.mock("@formbricks/database/prisma", async () => { ...actual, Prisma: actual.Prisma, PrismaClient: class { + // Better Auth 1.7 seeds its `oauthResource` rows when the oauthProvider plugin initialises + // (ENG-2343), which happens on any import of modules/auth/lib/auth.ts. Against this stub the + // adapter would otherwise throw `Model oauthResource does not exist in the database` as an + // unhandled error in every such test file — noise that would sit in the suite forever and + // mask a real failure later. A read that returns nothing lets seeding complete quietly; + // nothing here asserts on it, and the real behaviour is covered against a real database. + oauthResource = { + findMany: () => Promise.resolve([]), + findFirst: () => Promise.resolve(null), + findUnique: () => Promise.resolve(null), + create: (args: { data: unknown }) => Promise.resolve(args.data), + createMany: () => Promise.resolve({ count: 0 }), + update: (args: { data: unknown }) => Promise.resolve(args.data), + upsert: (args: { create: unknown }) => Promise.resolve(args.create), + }; + $connect() { return Promise.resolve(); } diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx index 6917b0e3fa0f..57000f2b5428 100644 --- a/docs/self-hosting/advanced/migration.mdx +++ b/docs/self-hosting/advanced/migration.mdx @@ -11,6 +11,10 @@ Records API or the MCP server, and nothing else. It needs no environment variabl migrations. If you do not use Unify Feedback, or you only reach it through the UI, there is nothing to do for it. +v5.4 also upgrades the **Better Auth** authentication core to 1.7. That upgrade needs no action at all: +every migration is additive and applies automatically, and **your SSO callback URLs do not change** — see +below. + ### Feedback Record Mutations by API Key (action required for some integrations) A feedback dataset can be assigned to more than one workspace, and the records inside it carry no workspace @@ -73,6 +77,81 @@ This change adds no migrations and touches no schema, so rolling the application to v5.3 undoes it with nothing to restore. An API key raised to `manage` in step 2 keeps working on v5.3 — `manage` was always accepted wherever `write` was — so that step does not need reverting either. +### Better Auth 1.7 upgrade (no action required) + +v5.4 upgrades the Better Auth authentication core from the 1.6 line to 1.7, which fixes +[GHSA-p2fr-6hmx-4528](https://github.com/advisories/GHSA-p2fr-6hmx-4528): MCP OAuth access-token audiences +are now bound to the resource a user actually approved during authorization, instead of trusting whatever +the client requested. This is a coupled core upgrade — it touches login, sessions, 2FA, SSO and MCP OAuth — +but for self-hosted instances it needs no manual database step. Every schema change is additive and nullable, +and the required backfills (account identity, existing OAuth clients, refresh tokens, consents) run +automatically on startup. + +The migration also adds two nullable columns, `alg` and `crv`, to the `jwks` table that holds your +instance's JWT signing keys. Keys created before the upgrade keep working untouched — Better Auth reads a +null `alg` as the default algorithm — so there is nothing to backfill, but the columns must exist before +the new version mints its next key. + +#### SSO callback URLs are unchanged + +Your SSO callback URLs stay exactly as they are: + +| Provider | Callback URL | Action required? | +| --- | --- | --- | +| Azure AD / Entra ID | `https://your-domain.com/api/auth/oauth2/callback/azuread` | No | +| Generic OIDC / Keycloak | `https://your-domain.com/api/auth/oauth2/callback/openid` | No | +| SAML (BoxyHQ / Jackson) | `https://your-domain.com/api/auth/oauth2/callback/saml` | No | +| Google / GitHub | `https://your-domain.com/api/auth/callback/{provider}` | No | + +Better Auth 1.7 rebuilds its generic-OAuth support on top of the built-in social-provider route, which +would otherwise have moved these three callbacks to `/api/auth/callback/{providerId}` and required you to +register a new redirect URI at your identity provider. OAuth requires redirect URIs to match exactly, so a +path change means editing every provider configuration by hand. + +Formbricks pins the callback URL instead, so it no longer follows the authentication library's internal +routing. The URLs above are the ones introduced in v5.2 and they are now a stable part of the Formbricks +API — a future authentication-core upgrade will not move them either. + + + The table writes `your-domain.com` as your `WEBAPP_URL`, which is correct for a default install. If you + set `BETTER_AUTH_URL` or `NEXTAUTH_URL` to a different origin, the callback lives on **that** origin + instead — it is derived from the same auth base URL Better Auth itself uses, not from `WEBAPP_URL`. + Either way it is unchanged by this upgrade. + + + + Nothing to do, and nothing to undo. If you added a `/api/auth/callback/{providerId}` redirect URI in + advance based on an earlier draft of these notes, leaving it registered is harmless — most identity + providers accept several redirect URIs, and Formbricks simply will not use it. + + +#### MCP access tokens now end with their session + +An MCP access token used to stay valid until its own expiry, independently of the session that authorized +it. In 1.7 it is bound to that session: once the session ends — the user signs out, or the session is +revoked — the token is inactive. `/introspect` reports `active: false` for it and +`/api/auth/oauth2/userinfo` rejects it, rather than honouring it until its TTL runs out. + +This is upstream hardening, and it is the stricter of the two behaviours: a token that outlives the sign-out +which created it is exactly what a revocation is supposed to stop. + +It applies to **access** tokens. A refresh token granted `offline_access` deliberately survives the session +ending, per OIDC Back-Channel Logout 1.0 §2.7, so that long-lived API access can outlive a browser session — +which is what MCP clients rely on. Signing out is therefore not a way to cut a client off permanently. + +**No action is required.** MCP clients re-authorize on their own — Claude Code and MCP Inspector both do — +so a user who signs out and back in sees nothing. It is listed here for one case only: a custom integration +that caches a Formbricks MCP access token and reuses it across sessions will start receiving rejections +where it previously succeeded, and should obtain a fresh token instead of treating the cached one as valid +until its expiry. + +#### Rollback (Better Auth 1.7) + +The `Account.issuer` column and the new `oauthResource`, `oauthClientResource` and `oauthClientAssertion` +tables are additive and nullable, and are not read by v5.3, so rolling the application images / Helm values +back to v5.3 is safe without touching the database. SSO needs no attention in either direction: v5.3 and +v5.4 use the same callback URLs. + ## v5.3 Formbricks v5.3 is a **drop-in upgrade for most self-hosted instances**. There are no breaking API, diff --git a/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx b/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx index 4cf1c9658fee..acad7767429e 100644 --- a/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx +++ b/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx @@ -19,10 +19,16 @@ Do you have a Microsoft Entra ID Tenant? Integrate it with your Formbricks insta - The callback URI for your Formbricks instance: `{WEBAPP_URL}/api/auth/oauth2/callback/azuread` - **Upgrading from v5.1 or earlier?** This path changed in v5.2. The old URI was - `{WEBAPP_URL}/api/auth/callback/azure-ad`. Add the new one under **Redirect URIs** in your app - registration or logins will fail. See the [migration guide](/self-hosting/advanced/migration) for the - full list of changed callback URLs. + **Upgrading from v5.1 or earlier?** This path changed once, at v5.2: + + - v5.1 and earlier: `{WEBAPP_URL}/api/auth/callback/azure-ad` + - v5.2 and later (current): `{WEBAPP_URL}/api/auth/oauth2/callback/azuread` + + The two are distinct URIs, because the provider id itself also changed (`azure-ad` → `azuread`). Add + the current one under **Redirect URIs** in your app registration, or Azure AD sign-in fails with a + redirect-URI mismatch. Upgrading from v5.2 or later needs no change — the callback URL is pinned and + does not move with Formbricks' authentication library. See the + [migration guide](/self-hosting/advanced/migration). ## How to connect your Formbricks instance to Microsoft Entra diff --git a/docs/self-hosting/configuration/auth-sso/keycloak-oidc.mdx b/docs/self-hosting/configuration/auth-sso/keycloak-oidc.mdx index 9af40aed78cd..e9bf1190a399 100644 --- a/docs/self-hosting/configuration/auth-sso/keycloak-oidc.mdx +++ b/docs/self-hosting/configuration/auth-sso/keycloak-oidc.mdx @@ -15,10 +15,13 @@ Integrating Keycloak with your Formbricks instance allows users to log in using - The Formbricks callback URI: `{WEBAPP_URL}/api/auth/oauth2/callback/openid` - **Upgrading from v5.1 or earlier?** This path changed in v5.2. The old URI was - `{WEBAPP_URL}/api/auth/callback/openid` (without `/oauth2`). Add the new one to your Keycloak client's - **Valid redirect URIs** or logins will fail. See the - [migration guide](/self-hosting/advanced/migration) for the full list of changed callback URLs. + **Upgrading from v5.1 or earlier?** This path changed once, at v5.2: + + - v5.1 and earlier: `{WEBAPP_URL}/api/auth/callback/openid` + - v5.2 and later (current): `{WEBAPP_URL}/api/auth/oauth2/callback/openid` + + Upgrading from v5.2 or later needs no change — the callback URL is pinned and does not move with + Formbricks' authentication library. See the [migration guide](/self-hosting/advanced/migration). ## Setting up Keycloak OIDC @@ -190,7 +193,7 @@ AUTH_SSO_DEFAULT_TEAM_ID=your-team-id-here - Verify the **Valid redirect URI** in your Keycloak client matches exactly: `{WEBAPP_URL}/api/auth/oauth2/callback/openid` - - If you upgraded from v5.1 or earlier, note the path changed in v5.2 — the old `/api/auth/callback/openid` URI no longer works on its own. + - If you're on v5.1 or earlier, the path is `{WEBAPP_URL}/api/auth/callback/openid` (without `/oauth2`) instead. - Check that the OIDC issuer URL is reachable from your Formbricks server. You can test with: ```bash curl https://your-keycloak-domain.com/realms/your-realm-name/.well-known/openid-configuration diff --git a/docs/self-hosting/configuration/auth-sso/open-id-connect.mdx b/docs/self-hosting/configuration/auth-sso/open-id-connect.mdx index 8ab26e6ee165..c87182f8fda7 100644 --- a/docs/self-hosting/configuration/auth-sso/open-id-connect.mdx +++ b/docs/self-hosting/configuration/auth-sso/open-id-connect.mdx @@ -19,14 +19,18 @@ Integrating your own OIDC (OpenID Connect) instance with your Formbricks instanc - `OIDC_SIGNING_ALGORITHM` - Make sure the Redirect URI for your OIDC Client is set to `{WEBAPP_URL}/api/auth/oauth2/callback/openid`. + Make sure the Redirect URI for your OIDC Client is set to + `{WEBAPP_URL}/api/auth/oauth2/callback/openid`. - **Upgrading from v5.1 or earlier?** This path changed in v5.2. The old URI was - `{WEBAPP_URL}/api/auth/callback/openid` (without `/oauth2`). Add the new one at your OIDC provider or - logins will fail. See the [migration guide](/self-hosting/advanced/migration) for the full list of - changed callback URLs. + **Upgrading from v5.1 or earlier?** This path changed once, at v5.2: + + - v5.1 and earlier: `{WEBAPP_URL}/api/auth/callback/openid` + - v5.2 and later (current): `{WEBAPP_URL}/api/auth/oauth2/callback/openid` + + Upgrading from v5.2 or later needs no change — the callback URL is pinned and does not move with + Formbricks' authentication library. See the [migration guide](/self-hosting/advanced/migration). - Update these environment variables in your `docker-compose.yml` or pass it directly to the running container. diff --git a/packages/database/eslint.config.mjs b/packages/database/eslint.config.mjs index 778eefa70633..addb26207969 100644 --- a/packages/database/eslint.config.mjs +++ b/packages/database/eslint.config.mjs @@ -2,4 +2,14 @@ import library from "@formbricks/config-eslint/library"; // The library tier (eslint + typescript-eslint recommendedTypeChecked) does not enable `no-console`, // so the CLI/migration scripts here that log intentionally need no package-wide override. -export default library({ tsconfigRootDir: import.meta.dirname }); +export default [ + ...library({ tsconfigRootDir: import.meta.dirname }), + { + rules: { + // Runtime-only env read: the ENG-2343 data migration derives the deployment's MCP resource + // identifier from WEBAPP_URL when it runs, which is not a build input, so declaring it in + // turbo.json would only add it to the build hash for nothing. Same reasoning as packages/logger. + "turbo/no-undeclared-env-vars": ["error", { allowList: ["WEBAPP_URL"] }], + }, + }, +]; diff --git a/packages/database/migration/20260812110000_eng_2343_better_auth_17_resource_model/migration.sql b/packages/database/migration/20260812110000_eng_2343_better_auth_17_resource_model/migration.sql new file mode 100644 index 000000000000..7dc5b505cc55 --- /dev/null +++ b/packages/database/migration/20260812110000_eng_2343_better_auth_17_resource_model/migration.sql @@ -0,0 +1,183 @@ +-- ENG-2343: Better Auth 1.7 resource model + account identity. +-- +-- Two halves. Everything here is derivable without knowing the deployment's URLs. The rest — seeding +-- the oauthResource row and linking existing clients/tokens/consents to it — needs +-- `${WEBAPP_URL}/api/mcp`, which SQL cannot know, so it lives in the sibling data migration +-- 20260812110001_eng_2343_backfill_oauth_resource_links. +-- +-- The Account.issuer backfill below is NOT optional and NOT cosmetic. Better Auth 1.7 keys accounts on +-- (issuer, accountId) and filters every lookup on it, including findCredentialAccount (password +-- sign-in) and updatePassword (password reset). Shipping 1.7 against rows with a null issuer locks +-- every user out of password login, and their password reset silently matches zero rows. + +-- AlterTable +ALTER TABLE "oauthClient" ADD COLUMN IF NOT EXISTS "applicationType" TEXT, +ADD COLUMN IF NOT EXISTS "backchannelLogoutSessionRequired" BOOLEAN, +ADD COLUMN IF NOT EXISTS "backchannelLogoutUri" TEXT, +ADD COLUMN IF NOT EXISTS "clientCredentialsScopes" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN IF NOT EXISTS "clientDiscoveryId" TEXT, +ADD COLUMN IF NOT EXISTS "dpopBoundAccessTokens" BOOLEAN DEFAULT false, +ADD COLUMN IF NOT EXISTS "jwks" TEXT, +ADD COLUMN IF NOT EXISTS "jwksUri" TEXT; + +-- AlterTable +ALTER TABLE "oauthAccessToken" ADD COLUMN IF NOT EXISTS "authorizationCodeId" TEXT, +ADD COLUMN IF NOT EXISTS "confirmation" JSONB, +ADD COLUMN IF NOT EXISTS "requestedUserInfoClaims" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN IF NOT EXISTS "resources" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN IF NOT EXISTS "revoked" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "oauthRefreshToken" ADD COLUMN IF NOT EXISTS "authorizationCodeId" TEXT, +ADD COLUMN IF NOT EXISTS "confirmation" JSONB, +ADD COLUMN IF NOT EXISTS "requestedUserInfoClaims" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN IF NOT EXISTS "resources" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN IF NOT EXISTS "rotatedAt" TIMESTAMP(3), +ADD COLUMN IF NOT EXISTS "rotationReplayExpiresAt" TIMESTAMP(3), +ADD COLUMN IF NOT EXISTS "rotationReplayResponse" TEXT; + +-- AlterTable +ALTER TABLE "oauthConsent" ADD COLUMN IF NOT EXISTS "requestedUserInfoClaims" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN IF NOT EXISTS "resources" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "oauthResource" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "name" TEXT NOT NULL, + "accessTokenTtl" INTEGER, + "refreshTokenTtl" INTEGER, + "signingAlgorithm" TEXT, + "signingKeyId" TEXT, + "allowedScopes" TEXT[] DEFAULT ARRAY[]::TEXT[], + "customClaims" JSONB, + "dpopBoundAccessTokensRequired" BOOLEAN DEFAULT false, + "disabled" BOOLEAN DEFAULT false, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + "policyVersion" INTEGER DEFAULT 1, + "metadata" JSONB, + + CONSTRAINT "oauthResource_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "oauthClientResource" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3), + + CONSTRAINT "oauthClientResource_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "oauthClientAssertion" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "oauthClientAssertion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "oauthResource_identifier_key" ON "oauthResource"("identifier"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "oauthClientResource_resourceId_idx" ON "oauthClientResource"("resourceId"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "oauthClientResource_clientId_resourceId_key" ON "oauthClientResource"("clientId", "resourceId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "oauthClientAssertion_expiresAt_idx" ON "oauthClientAssertion"("expiresAt"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "oauthAccessToken_authorizationCodeId_idx" ON "oauthAccessToken"("authorizationCodeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "oauthRefreshToken_authorizationCodeId_idx" ON "oauthRefreshToken"("authorizationCodeId"); + +-- AddForeignKey +-- Guarded with DO/EXCEPTION rather than IF NOT EXISTS: Postgres has no IF NOT EXISTS for +-- ADD CONSTRAINT, and this migration must stay convergent against a database created with `db:push`, +-- where these constraints already exist. +DO $$ +BEGIN + ALTER TABLE "oauthClientResource" ADD CONSTRAINT "oauthClientResource_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- AddForeignKey +DO $$ +BEGIN + ALTER TABLE "oauthClientResource" ADD CONSTRAINT "oauthClientResource_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "oauthResource"("identifier") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- --------------------------------------------------------------------------- +-- JWT signing keyring (Better Auth 1.7) +-- --------------------------------------------------------------------------- + +-- Not cosmetic, despite both being declared `required: false` upstream. `createJwk` +-- (better-auth/plugins/jwt/utils) always puts `alg` in the row it writes — defaulting to EdDSA — and +-- adds `crv` whenever it can derive one, which it can for the default EdDSA/Ed25519 keyring we run. +-- Without these columns Prisma rejects that INSERT as an unknown argument, so the first key mint +-- fails, and with it JWT signing and the whole MCP OAuth flow. It surfaces only against a real +-- database on a deployment that has not minted a key yet (a fresh self-host, or after rotation) — +-- mocked unit tests and the in-memory DCR harness both write nothing here. +-- +-- Nullable because rows written before 1.7 carry no value, and upstream reads are explicit about it: +-- getLatestKeyByAlg treats `alg: null` as the configured default alg, so keys already in the table +-- keep signing without a backfill. +ALTER TABLE "jwks" ADD COLUMN IF NOT EXISTS "alg" TEXT, +ADD COLUMN IF NOT EXISTS "crv" TEXT; + +COMMENT ON COLUMN "jwks"."alg" IS 'JWS algorithm for this key. NULL on pre-1.7 rows, read as the configured default alg.'; +COMMENT ON COLUMN "jwks"."crv" IS 'Curve for EC/OKP keys (Ed25519 under the default keyring).'; + +-- --------------------------------------------------------------------------- +-- Account identity (Better Auth 1.7) +-- --------------------------------------------------------------------------- + +-- Nullable on purpose, although better-auth declares `issuer` required: a NOT NULL column would fail +-- every INSERT from a 1.6 pod still serving during a rolling deploy. +ALTER TABLE "Account" ADD COLUMN IF NOT EXISTS "issuer" TEXT; + +-- Backfill BEFORE the unique index, so a collision fails the migration here rather than surfacing as +-- a login bug later. Values mirror better-auth's own helpers (@better-auth/core db/schema/account): +-- createLocalAccountIssuer(id) => `local:${encodeURIComponent(id)}` -- credential accounts +-- createOAuthAccountIssuer(id) => `local:oauth:${encodeURIComponent(id)}` -- every OAuth provider +-- encodeURIComponent is the identity function for all provider ids in use here (credential, github, +-- google, azuread, openid, saml), so plain concatenation matches byte for byte. A provider id that +-- ever needs escaping must be added to this expression deliberately. +-- +-- This pairs with `accountIssuer` being pinned explicitly on the generic providers in +-- better-auth-providers.ts. Without that pin, discovery providers would derive a tenant-specific +-- issuer URL that no portable backfill could reproduce. +-- +-- One provider is NOT synthetic. A built-in social provider may declare its own `accountIssuer`, and +-- Better Auth then keys accounts on that instead of the local: form. Verified against +-- @better-auth/core/dist/social-providers for the two we enable: +-- google -> accountIssuer "https://accounts.google.com" (declared upstream) +-- github -> none declared, so the local:oauth: fallback applies +-- Getting google wrong would leave every existing Google user unmatched at sign-in. Any future +-- built-in social provider must be checked the same way before it is enabled. +UPDATE "Account" +SET "issuer" = CASE + WHEN "provider" = 'credential' THEN 'local:credential' + WHEN "provider" = 'google' THEN 'https://accounts.google.com' + ELSE 'local:oauth:' || "provider" +END +WHERE "issuer" IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS "Account_issuer_providerAccountId_key" ON "Account"("issuer", "providerAccountId"); + +COMMENT ON COLUMN "Account"."issuer" IS 'Better Auth 1.7 account identity namespace; half of the (issuer, accountId) key. Backfilled by ENG-2343.'; +COMMENT ON COLUMN "oauthAccessToken"."resources" IS 'RFC 8707 resource indicators this token was approved for (GHSA-p2fr-6hmx-4528).'; +COMMENT ON COLUMN "oauthRefreshToken"."resources" IS 'Full original resource grant; a refresh may narrow this set but never widen it.'; +COMMENT ON COLUMN "oauthConsent"."resources" IS 'Resources the user consented to; /authorize compares requested resources against these.'; +COMMENT ON TABLE "oauthResource" IS 'Protected resources the AS issues tokens for. Seeded from the oauthProvider `resources` option.'; +COMMENT ON TABLE "oauthClientResource" IS 'Client-to-resource grants. With enforcePerClientResources a missing row means invalid_target.'; diff --git a/packages/database/migration/20260812110001_eng_2343_backfill_oauth_resource_links/migration.ts b/packages/database/migration/20260812110001_eng_2343_backfill_oauth_resource_links/migration.ts new file mode 100644 index 000000000000..38556171ab77 --- /dev/null +++ b/packages/database/migration/20260812110001_eng_2343_backfill_oauth_resource_links/migration.ts @@ -0,0 +1,174 @@ +import { createId } from "@paralleldrive/cuid2"; +import type { MigrationScript } from "../../src/scripts/migration-runner"; + +/** + * ENG-2343 — the half of the Better Auth 1.7 resource migration that SQL cannot do. + * + * The sibling schema migration (20260812110000) adds every table, column and index, and backfills + * `Account.issuer`, because that value derives from `Account.provider` alone. Everything here needs + * the deployment's own MCP resource identifier — `${WEBAPP_URL}/api/mcp` — which is environment + * specific and differs for every self-hoster, so it has to be read from the environment at migration + * time rather than hardcoded in SQL. + * + * What breaks without it, on an instance that already has MCP clients: + * - `oauthClientResource`: with `enforcePerClientResources` (the 1.7 default) a client with no link + * row fails `invalid_target` at the token endpoint — *after* the user has consented. Every already + * registered MCP client would break, and we cannot ask end users' MCP clients to re-register. + * - `oauthRefreshToken.resources`: a refresh with no recorded resources mints a token with no + * audience, i.e. an opaque one the MCP resource server rejects. + * - `oauthConsent.resources`: `/authorize` compares requested resources against the consented set, so + * an empty set forces every live connection back through the consent screen. + * + * The `oauthResource` row itself is normally seeded by the plugin at boot from its `resources` + * option. We insert it here as well because `oauthClientResource.resourceId` is a foreign key to + * `oauthResource.identifier`, so the link rows cannot exist until it does, and migrations run before + * the app boots. Seeding is `insertOnly` upstream, so the plugin will not fight this row — which cuts + * BOTH ways and is why `allowedScopes` below is not optional: the plugin will not repair this row + * either. `allowedScopes` defaults to an empty array in the DDL, and `resolveResourcePolicy` skips only + * NULL/undefined (`introspect-*.mjs`) — an empty allow-list intersects every request down to zero + * scopes and throws `invalid_scope`. Omitting it would take MCP OAuth down permanently for exactly the + * instances this migration exists to protect. + * + * No-op on an empty database, as the harness requires: with no `oauthClient` rows there is nothing to + * link, and the resource row is only inserted when there is at least one client that needs it — a + * fresh install gets it from the plugin at first boot instead. + */ + +interface TMigrationTx { + $executeRaw: (query: TemplateStringsArray, ...values: readonly unknown[]) => Promise; + $queryRaw: (query: TemplateStringsArray, ...values: readonly unknown[]) => Promise; +} + +export interface TOauthResourceBackfillStats { + clients: number; + linked: number; + refreshTokens: number; + consents: number; +} + +const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); + +/** + * Mirrors `getMcpResourceUrl()` in apps/web/modules/auth/lib/oauth-urls.ts. Kept as a local copy on + * purpose: `packages/database` must not import from `apps/web`, and a migration has to keep producing + * the value that was correct when it ran even if the app-side helper later changes. + */ +/** + * The scopes the seeded resource must allow — a local copy of `MCP_OAUTH_SCOPES` from + * `apps/web/modules/auth/lib/oauth-urls.ts`, for the same reason `resolveMcpResourceIdentifier` below is + * one: `packages/database` cannot import from `apps/web`. + * + * A copy is only safe if something fails when the two diverge, so + * `apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts` asserts this list equals `MCP_OAUTH_SCOPES` + * exactly. Divergence is not cosmetic: a scope the app advertises but this row omits is intersected away + * at `/authorize`, so a client requesting it fails `invalid_scope`. + */ +export const MCP_RESOURCE_ALLOWED_SCOPES = [ + "openid", + "profile", + "email", + "offline_access", + "surveys:read", + "surveys:write", + "workflows:read", + "workflows:write", + "feedbackRecords:read", + "feedbackRecords:write", +] as const; + +export const resolveMcpResourceIdentifier = (webAppUrl: string | undefined): string | null => { + const configured = webAppUrl?.trim(); + if (!configured) return null; + + try { + const url = new URL(configured); + url.hash = ""; + url.search = ""; + const basePath = trimTrailingSlash(url.pathname); + return `${url.origin}${basePath.endsWith("/api/mcp") ? basePath : `${basePath}/api/mcp`}`; + } catch { + return null; + } +}; + +export const eng2343BackfillOauthResourceLinks: MigrationScript = { + type: "data", + id: "hqp3s7v1c0m9x4k2t8g6nd5j", + name: "20260812110001_eng_2343_backfill_oauth_resource_links", + run: async ({ tx }) => { + const migrationTx = tx as unknown as TMigrationTx; + + const [{ count: clientCount }] = await migrationTx.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*)::bigint AS count FROM "oauthClient" + `; + const clients = Number(clientCount); + + // Fresh database: the plugin seeds the resource at boot and there is nothing to link. + if (clients === 0) { + return; + } + + const resourceIdentifier = resolveMcpResourceIdentifier(process.env.WEBAPP_URL); + if (!resourceIdentifier) { + // Deliberately loud. Existing MCP clients will fail `invalid_target` until this is backfilled, + // and continuing quietly would hide that until a user hits it. + throw new Error( + "ENG-2343: WEBAPP_URL is not set or is not a valid URL, so the MCP resource identifier cannot " + + "be derived. Existing OAuth clients cannot be linked to their resource. Set WEBAPP_URL and " + + "re-run the migration." + ); + } + + // Matches what the plugin would seed from its `resources` option, so `insertOnly` seeding at boot + // is a no-op afterwards. `name` is descriptive only. + await migrationTx.$executeRaw` + INSERT INTO "oauthResource" ("id", "identifier", "name", "allowedScopes", "createdAt", "updatedAt") + VALUES (${createId()}, ${resourceIdentifier}, 'Formbricks MCP', ${[ + ...MCP_RESOURCE_ALLOWED_SCOPES, + ]}::TEXT[], NOW(), NOW()) + ON CONFLICT ("identifier") DO NOTHING + `; + + // One statement per client rather than an INSERT..SELECT: ids must be cuid2 to match the rest of + // the schema (`advanced.database.generateId` forces cuid2 app-wide), and a set-based insert would + // have to fall back to a database-side uuid. The row count here is the number of registered OAuth + // clients, so the loop is not a concern. + const clientRows = await migrationTx.$queryRaw<{ clientId: string }[]>` + SELECT "clientId" FROM "oauthClient" + `; + + let linked = 0; + for (const { clientId } of clientRows) { + linked += Number( + await migrationTx.$executeRaw` + INSERT INTO "oauthClientResource" ("id", "clientId", "resourceId", "createdAt") + VALUES (${createId()}, ${clientId}, ${resourceIdentifier}, NOW()) + ON CONFLICT ("clientId", "resourceId") DO NOTHING + ` + ); + } + + const refreshTokens = await migrationTx.$executeRaw` + UPDATE "oauthRefreshToken" + SET "resources" = ARRAY[${resourceIdentifier}]::TEXT[] + WHERE "resources" IS NULL OR cardinality("resources") = 0 + `; + + const consents = await migrationTx.$executeRaw` + UPDATE "oauthConsent" + SET "resources" = ARRAY[${resourceIdentifier}]::TEXT[] + WHERE "resources" IS NULL OR cardinality("resources") = 0 + `; + + const stats: TOauthResourceBackfillStats = { + clients, + linked, + refreshTokens: Number(refreshTokens), + consents: Number(consents), + }; + + // Migration progress, matching the sibling data migrations. `no-console` is not enabled for this + // package (see its eslint.config.mjs), so no disable directive is needed. + console.log(`ENG-2343 oauth resource backfill: ${JSON.stringify(stats)}`); + }, +}; diff --git a/packages/database/schema/main.prisma b/packages/database/schema/main.prisma index cedba4a3654e..84f842b3c2e4 100644 --- a/packages/database/schema/main.prisma +++ b/packages/database/schema/main.prisma @@ -851,7 +851,22 @@ model Account { accessTokenExpiresAt DateTime? refreshTokenExpiresAt DateTime? + /// Better Auth 1.7 (ENG-2343): the account's identity namespace. 1.7 keys accounts on + /// (issuer, accountId) and filters EVERY lookup on it — findAccountByKey, findAccountOwnerByKey, + /// findCredentialAccount and updatePassword. A null here is not cosmetic: password sign-in stops + /// finding the credential row (rejects as if the password were wrong) and a password reset matches + /// zero rows, so it silently succeeds while changing nothing. + /// + /// NULLABLE on purpose, even though better-auth declares it required. A NOT NULL column would fail + /// every INSERT from a 1.6 pod still serving during a rolling deploy. The migration backfills every + /// existing row; see the migration SQL for the value derivation. + issuer String? + + // Kept alongside the new (issuer, accountId) key rather than replaced: it is what 1.6 matched on, + // so it keeps a rollback to 1.6 working and guards against a backfill that collapses two providers + // onto one issuer. @@unique([provider, providerAccountId]) + @@unique([issuer, providerAccountId]) @@index([userId]) } @@ -913,44 +928,73 @@ model jwks { privateKey String createdAt DateTime expiresAt DateTime? + /// Better Auth 1.7 (ENG-2343): the JWS algorithm this key signs with, so a keyring can hold more + /// than one alg and `getLatestKeyByAlg` can pick the right key per resource. + /// + /// NOT optional upstream in practice, despite being declared `required: false`: `createJwk` always + /// puts `alg` in the row it writes (defaulting to EdDSA), so without this column every key mint is + /// rejected by Prisma as an unknown argument — which breaks JWT signing, and with it the whole MCP + /// OAuth flow, on any deployment that has yet to mint a key. Nullable because rows written before + /// 1.7 have no value; upstream reads treat `alg: null` as the configured default alg, so existing + /// keys keep working untouched. + alg String? + /// Better Auth 1.7 (ENG-2343): the curve for EC/OKP keys. Written whenever it can be derived from + /// the generated key, which it can for the default EdDSA/Ed25519 config we run, so it is on the + /// same always-written path as `alg` above. + crv String? } /// Better Auth OAuth 2.1 client registrations for MCP and future OAuth clients. model oauthClient { - id String @id @default(cuid()) - clientId String @unique + id String @id @default(cuid()) + clientId String @unique clientSecret String? - disabled Boolean? @default(false) + disabled Boolean? @default(false) skipConsent Boolean? enableEndSession Boolean? subjectType String? - scopes String[] @default([]) + scopes String[] @default([]) userId String? - user User? @relation("OAuthClientUser", fields: [userId], references: [id], onDelete: Cascade) + user User? @relation("OAuthClientUser", fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime? updatedAt DateTime? name String? uri String? icon String? - contacts String[] @default([]) + contacts String[] @default([]) tos String? policy String? softwareId String? softwareVersion String? softwareStatement String? redirectUris String[] - postLogoutRedirectUris String[] @default([]) + postLogoutRedirectUris String[] @default([]) tokenEndpointAuthMethod String? - grantTypes String[] @default([]) - responseTypes String[] @default([]) + grantTypes String[] @default([]) + responseTypes String[] @default([]) + /// Unwritten by Better Auth 1.7 (it dropped both from its schema), kept deliberately: they are + /// nullable, so keeping them makes the 1.7 migration purely additive and a rollback to 1.6 needs no + /// down-migration. Drop them in a separate cleanup after a bake period. public Boolean? type String? requirePKCE Boolean? referenceId String? metadata Json? - accessTokens oauthAccessToken[] - refreshTokens oauthRefreshToken[] - consents oauthConsent[] + + /// Better Auth 1.7 (ENG-2343). + clientDiscoveryId String? + clientCredentialsScopes String[] @default([]) + backchannelLogoutUri String? + backchannelLogoutSessionRequired Boolean? + applicationType String? + jwks String? + jwksUri String? + dpopBoundAccessTokens Boolean? @default(false) + + accessTokens oauthAccessToken[] + refreshTokens oauthRefreshToken[] + consents oauthConsent[] + resources oauthClientResource[] @@index([userId]) } @@ -973,10 +1017,19 @@ model oauthAccessToken { createdAt DateTime scopes String[] + /// Better Auth 1.7 (ENG-2343). `resources` is the RFC 8707 resource set the grant approved — the + /// binding this whole upgrade exists to get (GHSA-p2fr-6hmx-4528). + authorizationCodeId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + revoked DateTime? + confirmation Json? + @@index([clientId]) @@index([sessionId]) @@index([userId]) @@index([refreshId]) + @@index([authorizationCodeId]) } /// Better Auth OAuth refresh tokens used by delegated MCP clients. The token column stores a hashed lookup value. @@ -997,9 +1050,20 @@ model oauthRefreshToken { scopes String[] accessTokens oauthAccessToken[] + /// Better Auth 1.7 (ENG-2343). `resources` carries the full original grant so a refresh can narrow + /// the resource set but never widen it. + authorizationCodeId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + rotatedAt DateTime? + rotationReplayResponse String? + rotationReplayExpiresAt DateTime? + confirmation Json? + @@index([clientId]) @@index([sessionId]) @@index([userId]) + @@index([authorizationCodeId]) } /// Better Auth OAuth user consent grants for delegated clients. @@ -1014,10 +1078,68 @@ model oauthConsent { createdAt DateTime updatedAt DateTime + /// Better Auth 1.7 (ENG-2343). /authorize compares the requested resources against these; without + /// them every pre-existing connection is forced back through the consent screen. + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + @@index([clientId]) @@index([userId]) } +/// Better Auth 1.7 (ENG-2343): a protected resource the authorization server issues tokens for, with +/// its own token lifetimes, scope allow-list and claims. Replaces the flat `validAudiences` option — +/// a token is now bound to the resource it was approved for instead of to a static allow-list. +/// `identifier` is the RFC 8707 `resource` value and is the natural key other rows point at. +model oauthResource { + id String @id @default(cuid()) + identifier String @unique + name String + accessTokenTtl Int? + refreshTokenTtl Int? + signingAlgorithm String? + signingKeyId String? + allowedScopes String[] @default([]) + customClaims Json? + dpopBoundAccessTokensRequired Boolean? @default(false) + disabled Boolean? @default(false) + createdAt DateTime? + updatedAt DateTime? + policyVersion Int? @default(1) + metadata Json? + clients oauthClientResource[] +} + +/// Better Auth 1.7 (ENG-2343): links a client to the resources it may request tokens for. With +/// `enforcePerClientResources` (the default) a missing row means `invalid_target` at the token +/// endpoint, which is why the migration backfills one row per existing client. +/// Both sides reference natural keys, matching the other oauth child tables. +model oauthClientResource { + id String @id @default(cuid()) + clientId String + client oauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + resourceId String + resource oauthResource @relation(fields: [resourceId], references: [identifier], onDelete: Cascade) + metadata Json? + createdAt DateTime? + + // No separate @@index([clientId]): the composite unique above is a btree led by clientId, so it + // already serves every lookup and range scan on clientId alone. The sibling oauthClient* models do + // carry one, but none of them has a composite unique starting with clientId. + @@unique([clientId, resourceId]) + @@index([resourceId]) +} + +/// Better Auth 1.7 (ENG-2343): replay store for `private_key_jwt` client assertions (the row id is the +/// assertion jti). We use no private_key_jwt client authentication today, but the plugin declares the +/// table, so omitting it risks an adapter error on a code path we do not control. +model oauthClientAssertion { + id String @id @default(cuid()) + expiresAt DateTime + + @@index([expiresAt]) +} + /// Stores the active password reset token for a user. /// Tokens are opaque, hashed at rest, revocable, and single-use. model PasswordResetToken { diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 917e9b54a3ff..265e0c2542ac 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -777,6 +777,13 @@ async function main(): Promise { provider: "credential", providerAccountId: id, password: passwordHash, + // Better Auth 1.7 keys the account on `(issuer, accountId)` and `findCredentialAccount` filters + // on `issuer`, so a seeded row without one cannot sign in: the correct password returns + // INVALID_EMAIL_OR_PASSWORD, and `updatePassword` matches zero rows so the reset escape hatch + // silently does nothing (ENG-2343). Literal rather than `createLocalAccountIssuer` from + // `@better-auth/core/db`: `packages/database` does not depend on Better Auth, and this must match + // what the 20260812110000 backfill writes for `provider = 'credential'` — which is this string. + issuer: "local:credential", }, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32361161bab4..b66beaac6a3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,11 +345,11 @@ importers: apps/web: dependencies: '@better-auth/core': - specifier: 1.6.23 - version: 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + specifier: 1.7.0 + version: 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/oauth-provider': - specifier: 1.6.23 - version: 1.6.23(318efca8bd447cadcd3b2d8af4d22c46) + specifier: 1.7.0 + version: 1.7.0(a7d5e9ad522ca47a6d8a16da19a4e45c) '@better-auth/utils': specifier: 0.4.2 version: 0.4.2 @@ -585,8 +585,8 @@ importers: specifier: 'catalog:' version: 3.0.3 better-auth: - specifier: 1.6.23 - version: 1.6.23(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + specifier: 1.7.0 + version: 1.7.0(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) boring-avatars: specifier: 2.0.4 version: 2.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -634,7 +634,7 @@ importers: version: 11.1.8 isomorphic-dompurify: specifier: 'catalog:' - version: 3.9.0(@noble/hashes@2.0.1) + version: 3.9.0(@noble/hashes@2.3.0) jiti: specifier: 2.6.1 version: 2.6.1 @@ -887,7 +887,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/ai: dependencies: @@ -936,7 +936,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/cache: dependencies: @@ -970,7 +970,7 @@ importers: version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/config-eslint: dependencies: @@ -1118,7 +1118,7 @@ importers: version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/email: dependencies: @@ -1167,7 +1167,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/i18n-utils: dependencies: @@ -1204,7 +1204,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/jobs: dependencies: @@ -1247,7 +1247,7 @@ importers: version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/js-core: devDependencies: @@ -1277,7 +1277,7 @@ importers: version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/logger: dependencies: @@ -1314,7 +1314,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/storage: dependencies: @@ -1348,7 +1348,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/survey-ui: dependencies: @@ -1384,7 +1384,7 @@ importers: version: 4.1.0 isomorphic-dompurify: specifier: 'catalog:' - version: 3.9.0(@noble/hashes@2.0.1) + version: 3.9.0(@noble/hashes@2.3.0) lucide-react: specifier: 'catalog:' version: 0.577.0(react@19.2.6) @@ -1430,7 +1430,7 @@ importers: version: 4.1.6(vitest@4.1.6) jsdom: specifier: 'catalog:' - version: 29.1.1(@noble/hashes@2.0.1) + version: 29.1.1(@noble/hashes@2.3.0) preact: specifier: 'catalog:' version: 10.29.1 @@ -1460,7 +1460,7 @@ importers: version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/surveys: dependencies: @@ -1478,7 +1478,7 @@ importers: version: 2.4.3(intl-messageformat@10.7.18) isomorphic-dompurify: specifier: 'catalog:' - version: 3.9.0(@noble/hashes@2.0.1) + version: 3.9.0(@noble/hashes@2.3.0) preact: specifier: 'catalog:' version: 10.29.1 @@ -1554,7 +1554,7 @@ importers: version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) zod: specifier: 'catalog:' version: 4.3.6 @@ -1597,7 +1597,7 @@ importers: version: 7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages/workflows: dependencies: @@ -1637,7 +1637,7 @@ importers: version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -2276,14 +2276,14 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@better-auth/core@1.6.23': - resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==} + '@better-auth/core@1.7.0': + resolution: {integrity: sha512-tUYocrJx6vYyf0k9CTAgAYtUFFRZIrcwNIhG3+WSUQGTPrcyl/hbo29Y9ynF2LwOZOwIwWCiis1A6Iqej4jC/g==} peerDependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@cloudflare/workers-types': '>=4' '@opentelemetry/api': ^1.9.0 - better-call: 1.3.7 + better-call: 1.4.0 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 @@ -2293,55 +2293,55 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.23': - resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==} + '@better-auth/drizzle-adapter@1.7.0': + resolution: {integrity: sha512-PQ7kT9unMmRFl8u3iwri31Ztx3vluuWL4Lq/SRpOpdU5Y0BMII5zpNLfahY5DOVRg9SHAcnWjs0gcwRxxQMuWQ==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 - drizzle-orm: ^0.45.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.23': - resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==} + '@better-auth/kysely-adapter@1.7.0': + resolution: {integrity: sha512-hpLMMJiLGsLF35D6Z0dMRtyax4c1uKMRgCNeHs8v+hBVJr8gh+PuKTPAS4p7P9gAYSz74kqG+i/+Ot/EnBl6xA==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.23': - resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==} + '@better-auth/memory-adapter@1.7.0': + resolution: {integrity: sha512-87D5BW931Uh+ap8al7nPZZjLHK3Qje3KuvOJ4cJ9yLkb7n6+CLHJAbwJ8yDTe4RY52smCaMm+uEq3vCtlOmLUw==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.23': - resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==} + '@better-auth/mongo-adapter@1.7.0': + resolution: {integrity: sha512-2FPZ/gvFnkFWQowXmfcxL+Ae27ZFGmXSsldD7Z29Gneh17tJGPTPpIW22d/ci2J1+744f1bQku+PdCkbZ6j6JA==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/oauth-provider@1.6.23': - resolution: {integrity: sha512-1sDN+N4Sztmpk8ziCU3MXicxOTfvYoHvHvhJMQ7PSfr+pLXnYN+dJFI9S3zBRwstmTeJx/OhRIZWrwFJ0TgBnA==} + '@better-auth/oauth-provider@1.7.0': + resolution: {integrity: sha512-OSytLFh2jAWlcIqrC/jp16sjWTrjwbKH11Tw05jSjkFqUjyOfuURla29TpbyGv872B1IemwNGJryul7r3vspzA==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: ^1.6.23 - better-call: 1.3.7 + better-auth: ^1.7.0 + better-call: 1.4.0 - '@better-auth/prisma-adapter@1.6.23': - resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} + '@better-auth/prisma-adapter@1.7.0': + resolution: {integrity: sha512-LLyyNaXzZrUZ4wLEp58JzpCgbQvuyZFCZqinqd8LTfMwuZG6Z+DEDKIkIsCfUb5wrZjUZ+v2o898EEO0yLHnwQ==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -2351,16 +2351,19 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.23': - resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==} + '@better-auth/telemetry@1.7.0': + resolution: {integrity: sha512-sIYgwq/Oc/QlgBGNjWju0gwa1hWlkrrpdTiPd7gGBMS9nzp+6l9zU6k6QFug6wjbDSzUsRHOZIXs2EIn9NcOFQ==} peerDependencies: - '@better-auth/core': ^1.6.23 + '@better-auth/core': ^1.7.0 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@better-auth/utils@0.4.2': resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} @@ -3277,6 +3280,10 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -4111,6 +4118,10 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@opentelemetry/sql-common@0.41.2': resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -7222,8 +7233,8 @@ packages: peerDependencies: ajv: 4.11.8 - 8 - better-auth@1.6.23: - resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==} + better-auth@1.7.0: + resolution: {integrity: sha512-azEG/7e4TLZ25TvvgcsolnzOzLfDFovdIYUb7xKeZQOm0p6Vj+rRKKaIvwE4MvXZCff6NAiIH2dMPl8O5yxI+A==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -7231,8 +7242,8 @@ packages: '@tanstack/react-start': ^1.0.0 '@tanstack/solid-start': ^1.0.0 better-sqlite3: ^12.0.0 - drizzle-kit: '>=0.31.4' - drizzle-orm: ^0.45.2 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 mongodb: ^6.0.0 || ^7.0.0 mysql2: ^3.0.0 next: ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -7284,8 +7295,8 @@ packages: vue: optional: true - better-call@1.3.7: - resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -8298,6 +8309,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -9218,6 +9230,9 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + jotai@2.20.0: resolution: {integrity: sha512-b5GAqgmXmXzB4WPaTH26ppk9Sl7AA9WSQX7yfdM+gJ1rFROiWcVbi97gFuN/yVCojOcbcvop2sfLL+fjxW0JVg==} engines: {node: '>=12.20.0'} @@ -11033,8 +11048,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rou3@0.7.12: - resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} @@ -11158,8 +11173,8 @@ packages: set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -13979,13 +13994,13 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0)': + '@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.7(zod@4.3.6) + better-call: 1.4.0(zod@4.3.6) jose: 6.2.2 kysely: 0.29.2 nanostores: 1.3.0 @@ -13993,51 +14008,65 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 - '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.9)(kysely@0.29.2)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.3.6) + jose: 6.2.9 + kysely: 0.29.2 + nanostores: 1.3.0 + zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + + '@better-auth/drizzle-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.2 - '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))': + '@better-auth/mongo-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7) - '@better-auth/oauth-provider@1.6.23(318efca8bd447cadcd3b2d8af4d22c46)': + '@better-auth/oauth-provider@1.7.0(a7d5e9ad522ca47a6d8a16da19a4e45c)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.23(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) - better-call: 1.3.7(zod@4.3.6) - jose: 6.2.2 + better-auth: 1.7.0(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + better-call: 1.4.0(zod@4.3.6) + jose: 6.2.9 zod: 4.3.6 - '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))': + '@better-auth/prisma-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -14045,6 +14074,10 @@ snapshots: dependencies: '@noble/hashes': 2.0.1 + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.0.1 + '@better-fetch/fetch@1.3.1': {} '@boxyhq/error-code-mnemonic@0.1.1': {} @@ -14379,6 +14412,10 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 + '@exodus/bytes@1.15.0(@noble/hashes@2.3.0)': + optionalDependencies: + '@noble/hashes': 2.3.0 + '@exodus/schemasafe@1.3.0': {} '@faker-js/faker@7.6.0': {} @@ -15002,6 +15039,8 @@ snapshots: '@noble/hashes@2.0.1': {} + '@noble/hashes@2.3.0': {} + '@nodable/entities@2.1.0': {} '@nodelib/fs.scandir@2.1.5': @@ -16171,6 +16210,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} + '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -19110,7 +19151,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) typescript: 5.9.3 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -19675,22 +19716,22 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - better-auth@1.6.23(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): + better-auth@1.7.0(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7)) - '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)) - '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.9)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7)) + '@better-auth/prisma-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)) + '@better-auth/telemetry': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 - '@noble/hashes': 2.0.1 - better-call: 1.3.7(zod@4.3.6) + '@noble/hashes': 2.3.0 + better-call: 1.4.0(zod@4.3.6) defu: 6.1.7 - jose: 6.2.2 + jose: 6.2.9 kysely: 0.29.2 nanostores: 1.3.0 zod: 4.3.6 @@ -19709,12 +19750,12 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.7(zod@4.3.6): + better-call@1.4.0(zod@4.3.6): dependencies: - '@better-auth/utils': 0.4.2 + '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 - rou3: 0.7.12 - set-cookie-parser: 3.1.0 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 optionalDependencies: zod: 4.3.6 @@ -20254,10 +20295,10 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@7.0.0(@noble/hashes@2.0.1): + data-urls@7.0.0(@noble/hashes@2.3.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.0.1) + whatwg-url: 16.0.1(@noble/hashes@2.3.0) transitivePeerDependencies: - '@noble/hashes' @@ -21472,6 +21513,12 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-encoding-sniffer@6.0.0(@noble/hashes@2.3.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.3.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-parse-stringify@3.0.1: @@ -21832,10 +21879,10 @@ snapshots: isexe@2.0.0: {} - isomorphic-dompurify@3.9.0(@noble/hashes@2.0.1): + isomorphic-dompurify@3.9.0(@noble/hashes@2.3.0): dependencies: dompurify: 3.4.13 - jsdom: 29.1.1(@noble/hashes@2.0.1) + jsdom: 29.1.1(@noble/hashes@2.3.0) transitivePeerDependencies: - '@noble/hashes' - canvas @@ -21904,6 +21951,8 @@ snapshots: jose@6.2.2: {} + jose@6.2.9: {} + jotai@2.20.0(@babel/core@7.29.6)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6): optionalDependencies: '@babel/core': 7.29.6 @@ -21937,7 +21986,7 @@ snapshots: '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) css-tree: 3.2.1 - data-urls: 7.0.0(@noble/hashes@2.0.1) + data-urls: 7.0.0(@noble/hashes@2.3.0) decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 @@ -21950,7 +21999,33 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.0.1) + whatwg-url: 16.0.1(@noble/hashes@2.3.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsdom@29.1.1(@noble/hashes@2.3.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@2.3.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.3.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.3.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -23856,7 +23931,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - rou3@0.7.12: {} + rou3@0.9.2: {} router@2.2.0: dependencies: @@ -23996,7 +24071,7 @@ snapshots: set-cookie-parser@2.7.1: {} - set-cookie-parser@3.1.0: {} + set-cookie-parser@3.1.2: {} set-function-length@1.2.2: dependencies: @@ -25108,7 +25183,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@7.3.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -25135,11 +25210,11 @@ snapshots: '@types/node': 25.4.0 '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) happy-dom: 20.8.9 - jsdom: 29.1.1(@noble/hashes@2.0.1) + jsdom: 29.1.1(@noble/hashes@2.3.0) transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -25166,7 +25241,7 @@ snapshots: '@types/node': 25.4.0 '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) happy-dom: 20.8.9 - jsdom: 29.1.1(@noble/hashes@2.0.1) + jsdom: 29.1.1(@noble/hashes@2.3.0) transitivePeerDependencies: - msw optional: true @@ -25294,9 +25369,9 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 - whatwg-url@16.0.1(@noble/hashes@2.0.1): + whatwg-url@16.0.1(@noble/hashes@2.3.0): dependencies: - '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + '@exodus/bytes': 1.15.0(@noble/hashes@2.3.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 32f852632ae2..3e086bada8fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -300,54 +300,19 @@ overrides: # What makes it tolerable is the input, not the dependency type: every one of these call sites expands # glob patterns supplied by library code or developer config (gaxios' cache cleanup, typeorm's entity # discovery, build and lint globs), never by request data. - # === Accepted risk, no override possible yet (pnpm audit will keep flagging this) === - # ENG-1703 / GHSA-p2fr-6hmx-4528 (Dependabot alerts #612 and #613, both dismissed as tolerable risk; - # the ticket is closed Done with the same conclusion as here). Re-verified 2026-08-05. - # @better-auth/oauth-provider 1.6.23 (used for the MCP OAuth flow, ENG-1055) fails to bind - # access-token audiences to their authorization grant (RFC 8707 resource-indicator confusion) — a - # client can complete a normal authorization flow and then request a JWT scoped to a resource server - # the authorization never covered. No pin can close it: the whole 1.6.x stable line is in range, and the - # patch ships only from 1.7.0-beta.4 — a line still in pre-release. (Deliberately version-agnostic: the - # `latest` and `rc` tags move about weekly, so naming them here just guarantees this note rots. The - # rc-specific claims below are pinned to 1.7.0-rc.4, the release actually inspected.) - # Why this deployment isn't exposed to the escalation — and what that rests on. Note the advisory is - # explicit that a single-audience deployment still does not bind the resource to the authorization - # grant, so the spec-compliance gap remains regardless of the below; this is "not exploitable here", - # not "compliant": - # - apps/web/modules/auth/lib/mcp-oauth-provider-options.ts sets a single-entry validAudiences (the - # MCP resource URL), so there is no second audience for a client to escalate into. This is the - # load-bearing mitigation, and it holds only while that array stays single-entry. - # - apps/web/modules/mcp/auth.ts pins `audience: getMcpResourceUrl()` on the resource server's own - # verify call AND, since ENG-2339, asserts the verified payload's `aud` itself. The pin alone was - # never sufficient: it flows straight into jose's jwtVerify, whose `aud` check is a membership - # test, so a token whose `aud` array also lists other audiences passes it. The advisory's own - # workaround requires *rejecting* multi-audience tokens, which verifyOptions.audience cannot - # express. The explicit assert is that rejection — an allow-list of {MCP resource, the AS's - # UserInfo endpoint, which `openid` implies}, everything else refused. - # - What that does and does not buy: our MCP endpoint can no longer be reached with a token minted - # for somebody else, which is the exploitable half. It does NOT make the AS compliant — 1.6.x - # still never persists the approved resource, so there is nothing to bind the token request to. - # That is unfixable from our side and is what the 1.7 upgrade (ENG-2343) is for. If a second - # audience were ever added the party at risk is that other resource server, and nothing here - # protects it. - # - Test coverage. Single-entry validAudiences is pinned twice, deliberately: in - # apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts (added by #8828) and in - # apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts (ENG-2339), which also covers the - # advertised-vs-registration scope agreement and the write-scope step-up expiry. Redundant on - # purpose — it is the invariant this entire acceptance rests on. Separately, - # apps/web/modules/mcp/auth.test.ts mints real signed JWTs and proves a multi-audience token is - # refused (and that jose on its own accepts it). - # Upgrade cost, for whoever picks up 1.7.0: the renames the 1.7 upgrade guide warns about do not apply - # to us (migration 20260702093000_add_better_auth_oauth_provider already created `oauthClient`, not - # `oauthApplication`, with an `oauthAccessToken.token` column, not `.accessToken`, plus the `sessionId` - # that session-bound token validity needs — and we have no SCIM), and customAccessTokenClaims.resource - # does not apply either (ours only returns email/name). But there is still real DDL to write: as - # inspected at 1.7.0-rc.4, the line replaces `validAudiences` with a resources model and declares three - # tables we do not have — `oauthResource`, `oauthClientResource` and `oauthClientAssertion`. The bigger - # blocker is that the same release declares `better-auth` and `@better-auth/core` as matching - # `^1.7.0-rc.x` peers, making this a coupled auth-core upgrade touching login/2FA/SSO/sessions rather - # than a plugin-only bump. That needs its own reviewed PR once Better Auth ships 1.7.0 stable, not an - # audit sweep or an unattended dependency-only bump. + # === Closed by ENG-2343 (kept as a record, like the section above) === + # GHSA-p2fr-6hmx-4528 (@better-auth/oauth-provider RFC 8707 resource-indicator confusion) was carried + # here as an accepted risk for as long as the only patched line was a pre-release. It is fixed as of + # better-auth 1.7.0 stable, which this workspace now pins, so `pnpm audit` no longer reports it and + # the ~50 lines of justification that used to sit here are gone with it. What replaced them is real + # enforcement: the AS binds each token to the resources the authorization actually approved. + # + # One thing that did NOT go away: the resource server in apps/web/modules/mcp/auth.ts still asserts + # the verified `aud` against its own allow-list (ENG-2339). That is not leftover scaffolding from this + # acceptance — `hasAcceptedMcpAudience` answers "is this token for ME", which is a different + # question from "does each `aud` resolve to a registered resource"; only the first is the one a + # resource server must answer. (An earlier note here claimed 1.7 drops `verifyOptions.audience` + # from its own jwtVerify. That could not be substantiated and is retracted — see mcp/auth.ts.) autoInstallPeers: true linkWorkspacePackages: true