From 866fad3e6ca2630e1d52e6f943b612f663f60a37 Mon Sep 17 00:00:00 2001 From: RYAN AKMAL PASYA Date: Tue, 17 Feb 2026 21:05:33 +0700 Subject: [PATCH 1/6] chore(tests): configure Jest for Next.js application with custom settings --- jest.config.js | 30 + jest.setup.js | 33 + package.json | 5 + pnpm-lock.yaml | 1970 +++++++++++++++++++++++++++++++++++++++++++++++- turbo.json | 2 + 5 files changed, 2031 insertions(+), 9 deletions(-) create mode 100644 jest.config.js create mode 100644 jest.setup.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..2a63041 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,30 @@ +const nextJest = require("next/jest"); + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: "./apps/web", +}); + +// Add any custom config to be passed to Jest +const customJestConfig = { + // Add more setup options before each test is run + setupFilesAfterEnv: ["/jest.setup.js"], + testEnvironment: "jest-environment-jsdom", + moduleNameMapper: { + "^@/(.*)$": "/apps/web/$1", + "^@workspace/ui/(.*)$": "/packages/ui/src/$1", + "^@workspace/ui$": "/packages/ui/src/index.ts", + "^@workspace/backend/(.*)$": "/packages/backend/$1", + }, + testMatch: ["/apps/web/__tests__/**/*.test.{js,jsx,ts,tsx}"], + collectCoverageFrom: [ + "apps/web/**/*.{js,jsx,ts,tsx}", + "!apps/web/**/*.d.ts", + "!apps/web/**/*.stories.{js,jsx,ts,tsx}", + "!apps/web/.next/**", + "!apps/web/node_modules/**", + ], +}; + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(customJestConfig); diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..3fc307f --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,33 @@ +// Learn more: https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom"; + +// Mock next/navigation +jest.mock("next/navigation", () => ({ + useRouter() { + return { + push: jest.fn(), + pathname: "/", + query: {}, + asPath: "/", + }; + }, + usePathname() { + return "/"; + }, + useSearchParams() { + return new URLSearchParams(); + }, + useMatches() { + return []; + }, +})); + +// Mock clerk +jest.mock("@clerk/nextjs", () => ({ + useAuth: jest.fn(), + useClerk: jest.fn(), + useUser: jest.fn(), + SignIn: jest.fn(() => null), + SignUp: jest.fn(() => null), + ClerkProvider: ({ children }) => children, +})); diff --git a/package.json b/package.json index d40337e..235994c 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,14 @@ "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jest": "^30.0.0", "@workspace/eslint-config": "workspace:*", "@workspace/typescript-config": "workspace:*", + "jest": "^30.2.0", "prettier": "^3.7.4", + "ts-jest": "^29.4.6", "turbo": "^2.6.3", "typescript": "5.7.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd974a4..6d41412 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,15 +11,30 @@ importers: .: devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@workspace/eslint-config': specifier: workspace:* version: link:packages/eslint-config '@workspace/typescript-config': specifier: workspace:* version: link:packages/typescript-config + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) prettier: specifier: ^3.7.4 version: 3.7.4 + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)))(typescript@5.7.3) turbo: specifier: ^2.6.3 version: 2.6.3 @@ -43,7 +58,7 @@ importers: version: 3.0.23(zod@3.25.76) '@clerk/nextjs': specifier: ^6.37.0 - version: 6.37.0(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 6.37.0(next@16.0.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@tabler/icons-react': specifier: ^3.36.1 version: 3.36.1(react@19.2.1) @@ -88,13 +103,16 @@ importers: version: 8.5.9(@types/react@19.2.7)(react@19.2.1) recharts: specifier: ^3.7.0 - version: 3.7.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react-is@16.13.1)(react@19.2.1)(redux@5.0.1) + version: 3.7.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react-is@18.3.1)(react@19.2.1)(redux@5.0.1) remark-gfm: specifier: ^4.0.1 version: 4.0.1 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@tailwindcss/postcss': specifier: ^4.1.17 @@ -389,6 +407,9 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/anthropic@3.0.40': resolution: {integrity: sha512-M852thU8WTReldaHNcTCGZWEwzkkxLM6fz+n8n7Yjdsxbh+wqqkJJoOX8iVWj5UuKHBV8TYQm+ey5ips0IJicw==} engines: {node: '>=18'} @@ -481,6 +502,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -502,6 +527,97 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime-corejs3@7.28.2': resolution: {integrity: sha512-FVFaVs2/dZgD3Y9ZD+AKNKjyGKzwu0C54laAXWUXgLcVXcCX6YZ6GhK2cp7FogSN2OA0Fu+QT8dP3FUdo9ShSQ==} engines: {node: '>=6.9.0'} @@ -522,6 +638,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@clerk/backend@2.29.6': resolution: {integrity: sha512-92I3/BA2d7zOl1y8IaKdDTYXm0UCskkhamhLT5fkOV+2oyxWQ2In/yK8FhO73pPyeCi2K/ZoXMSO4gBcoF9AAg==} engines: {node: '>=18.17.0'} @@ -992,10 +1111,104 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -1168,6 +1381,14 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1781,6 +2002,15 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@13.0.5': + resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -1982,6 +2212,29 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -2008,6 +2261,21 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2059,6 +2327,18 @@ packages: '@types/inquirer@6.5.0': resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2097,6 +2377,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} @@ -2112,6 +2395,12 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript-eslint/eslint-plugin@8.39.0': resolution: {integrity: sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2400,6 +2689,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -2408,12 +2701,27 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2421,6 +2729,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2488,6 +2799,31 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2526,6 +2862,16 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2558,6 +2904,14 @@ packages: camel-case@3.0.0: resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} @@ -2583,6 +2937,10 @@ packages: change-case@3.1.0: resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2617,6 +2975,13 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2639,6 +3004,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -2647,6 +3016,13 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2747,6 +3123,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2850,6 +3229,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2861,6 +3248,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -2892,6 +3283,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} @@ -2910,6 +3305,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2950,9 +3351,16 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2982,6 +3390,9 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -3033,6 +3444,10 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3216,10 +3631,18 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3250,6 +3673,9 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3271,6 +3697,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3289,6 +3719,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3315,6 +3749,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3323,6 +3761,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3353,6 +3795,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -3442,6 +3889,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3489,6 +3939,11 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3540,6 +3995,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -3586,6 +4044,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + is-generator-function@1.1.0: resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} engines: {node: '>= 0.4'} @@ -3689,10 +4151,161 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jiti@2.5.1: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true @@ -3715,6 +4328,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3734,6 +4351,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -3769,6 +4389,10 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3918,6 +4542,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3926,6 +4554,9 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3956,6 +4587,9 @@ packages: lower-case@1.1.4: resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3973,15 +4607,26 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -4133,6 +4778,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -4229,6 +4878,9 @@ packages: no-case@2.3.2: resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-plop@0.26.3: resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==} engines: {node: '>=8.9.4'} @@ -4236,6 +4888,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4302,10 +4958,18 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -4314,6 +4978,10 @@ packages: resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + pac-proxy-agent@7.2.0: resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} engines: {node: '>= 14'} @@ -4322,6 +4990,9 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + param-case@2.1.1: resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} @@ -4332,6 +5003,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -4362,6 +5037,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4400,6 +5079,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -4446,6 +5129,14 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -4463,6 +5154,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4483,6 +5177,12 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -4561,6 +5261,10 @@ packages: react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -4596,9 +5300,17 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4746,6 +5458,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4775,6 +5491,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -4786,12 +5505,19 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4805,10 +5531,18 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4842,14 +5576,26 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -4893,6 +5639,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -4905,6 +5655,10 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -4927,6 +5681,10 @@ packages: engines: {node: '>=18'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -4975,6 +5733,9 @@ packages: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5004,6 +5765,33 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -5087,10 +5875,18 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5256,6 +6052,10 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5342,6 +6142,9 @@ packages: jsdom: optional: true + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -5391,9 +6194,25 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -5401,6 +6220,14 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -5423,6 +6250,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@ai-sdk/anthropic@3.0.40(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -5545,6 +6374,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -5560,6 +6391,91 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime-corejs3@7.28.2': dependencies: core-js-pure: 3.45.0 @@ -5589,6 +6505,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} + '@clerk/backend@2.29.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@clerk/shared': 3.44.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -5606,7 +6524,7 @@ snapshots: react-dom: 19.2.1(react@19.2.1) tslib: 2.8.1 - '@clerk/nextjs@6.37.0(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@clerk/nextjs@6.37.0(next@16.0.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@clerk/backend': 2.29.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@clerk/clerk-react': 5.60.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -5968,10 +6886,208 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + + '@jest/core@30.2.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3))': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.0.1': {} + + '@jest/environment@30.2.0': + dependencies: + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + jest-mock: 30.2.0 + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.2.0': + dependencies: + expect: 30.2.0 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@sinonjs/fake-timers': 13.0.5 + '@types/node': 20.19.25 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.2.0': + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 20.19.25 + jest-regex-util: 30.0.1 + + '@jest/reporters@30.2.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.25 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/snapshot-utils@30.2.0': + dependencies: + '@jest/types': 30.2.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.2.0': + dependencies: + '@jest/test-result': 30.2.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + slash: 3.0.0 + + '@jest/transform@30.2.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.25 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.4 @@ -6108,6 +7224,11 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6668,6 +7789,16 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sinclair/typebox@0.34.48': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@13.0.5': + dependencies: + '@sinonjs/commons': 3.0.1 + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -6831,6 +7962,36 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.11 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tsconfig/node10@1.0.11': {} @@ -6880,6 +8041,29 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6935,7 +8119,22 @@ snapshots: '@types/through': 0.0.33 rxjs: 6.6.7 - '@types/json-schema@7.0.15': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.2.0 + pretty-format: 30.2.0 + + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -6973,6 +8172,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/stack-utils@2.0.3': {} + '@types/through@0.0.33': dependencies: '@types/node': 20.19.25 @@ -6985,6 +8186,12 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + '@typescript-eslint/eslint-plugin@8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.6.1))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -7312,6 +8519,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -7320,16 +8529,33 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + arg@4.1.3: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -7419,6 +8645,58 @@ snapshots: axobject-query@4.1.0: {} + babel-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.2.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -7458,6 +8736,16 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -7494,6 +8782,10 @@ snapshots: no-case: 2.3.2 upper-case: 1.1.3 + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + caniuse-lite@1.0.30001760: {} ccount@2.0.1: {} @@ -7543,6 +8835,8 @@ snapshots: upper-case: 1.1.3 upper-case-first: 1.1.2 + char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7584,6 +8878,10 @@ snapshots: chownr@3.0.0: {} + ci-info@4.4.0: {} + + cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7600,10 +8898,20 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} clsx@2.1.1: {} + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -7676,6 +8984,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -7758,12 +9068,16 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.1: {} + deep-eql@5.0.2: {} deep-extend@0.6.0: {} deep-is@0.1.4: {} + deepmerge@4.3.1: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -7803,6 +9117,8 @@ snapshots: detect-libc@2.1.2: {} + detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} devlop@1.1.0: @@ -7819,6 +9135,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -7864,8 +9184,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.286: {} + emittery@0.13.1: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -7891,6 +9215,10 @@ snapshots: entities@7.0.1: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -8029,6 +9357,8 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -8047,7 +9377,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@2.6.1)) @@ -8084,7 +9414,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -8099,7 +9429,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8348,8 +9678,19 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + exit-x@0.2.2: {} + expect-type@1.3.0: {} + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + extend@3.0.2: {} external-editor@3.1.0: @@ -8386,6 +9727,10 @@ snapshots: dependencies: reusify: 1.1.0 + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -8402,6 +9747,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -8424,6 +9774,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -8450,6 +9805,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8465,6 +9822,8 @@ snapshots: get-nonce@1.0.1: {} + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -8500,6 +9859,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -8610,6 +9978,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} htmlparser2@10.1.0: @@ -8658,6 +10028,11 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -8733,6 +10108,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -8781,6 +10158,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-generator-fn@2.1.0: {} + is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -8870,6 +10249,37 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -8879,6 +10289,324 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.2.0: + dependencies: + execa: 5.1.1 + jest-util: 30.2.0 + p-limit: 3.1.0 + + jest-circus@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.1 + is-generator-fn: 2.1.0 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + p-limit: 3.1.0 + pretty-format: 30.2.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)): + dependencies: + '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.25 + ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.7.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-docblock@30.2.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 + + jest-environment-node@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + + jest-haste-map@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + jest-worker: 30.2.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + jest-util: 30.2.0 + + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): + optionalDependencies: + jest-resolve: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.2.0: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.2.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.2.0: + dependencies: + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 30.2.0 + graceful-fs: 4.2.11 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.3 + synckit: 0.11.12 + transitivePeerDependencies: + - supports-color + + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jest-validate@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.2.0 + + jest-watcher@30.2.0: + dependencies: + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.2.0 + string-length: 4.0.2 + + jest-worker@30.2.0: + dependencies: + '@types/node': 20.19.25 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.2.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)): + dependencies: + '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@2.5.1: {} jiti@2.6.1: {} @@ -8891,6 +10619,11 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -8905,6 +10638,8 @@ snapshots: json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} json-schema@0.4.0: {} @@ -8940,6 +10675,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -9045,12 +10782,18 @@ snapshots: load-tsconfig@0.2.5: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.get@4.4.2: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -9078,6 +10821,8 @@ snapshots: lower-case@1.1.4: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9096,6 +10841,8 @@ snapshots: dependencies: react: 19.2.1 + lz-string@1.5.0: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.4 @@ -9104,8 +10851,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + make-error@1.3.6: {} + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -9465,6 +11220,8 @@ snapshots: mimic-fn@2.1.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -9558,6 +11315,8 @@ snapshots: dependencies: lower-case: 1.1.4 + node-int64@0.4.0: {} + node-plop@0.26.3: dependencies: '@babel/runtime-corejs3': 7.28.2 @@ -9574,6 +11333,8 @@ snapshots: node-releases@2.0.27: {} + normalize-path@3.0.0: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -9672,10 +11433,18 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -9684,6 +11453,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-try@2.2.0: {} + pac-proxy-agent@7.2.0: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 @@ -9702,6 +11473,8 @@ snapshots: degenerator: 5.0.1 netmask: 2.0.2 + package-json-from-dist@1.0.1: {} + param-case@2.1.1: dependencies: no-case: 2.3.2 @@ -9720,6 +11493,13 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -9750,6 +11530,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -9775,6 +11560,10 @@ snapshots: pirates@4.0.7: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -9811,6 +11600,18 @@ snapshots: prettier@3.7.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -9836,6 +11637,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + queue-microtask@1.2.3: {} rc@1.2.8: @@ -9857,6 +11660,10 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + + react-is@18.3.1: {} + react-markdown@10.1.0(@types/react@19.2.7)(react@19.2.1): dependencies: '@types/hast': 3.0.4 @@ -9932,7 +11739,7 @@ snapshots: readdirp@4.1.2: {} - recharts@3.7.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react-is@16.13.1)(react@19.2.1)(redux@5.0.1): + recharts@3.7.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react-is@18.3.1)(react@19.2.1)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.1)(redux@5.0.1))(react@19.2.1) clsx: 2.1.1 @@ -9942,7 +11749,7 @@ snapshots: immer: 10.2.0 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-is: 16.13.1 + react-is: 18.3.1 react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.1)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -9952,6 +11759,11 @@ snapshots: - '@types/react' - redux + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -10021,8 +11833,14 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + reselect@5.1.1: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -10231,6 +12049,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + slash@3.0.0: {} smart-buffer@4.2.0: {} @@ -10264,16 +12084,27 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.6.1: {} source-map@0.7.6: {} space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} stable-hash@0.0.5: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} standardwebhooks@1.0.0: @@ -10288,12 +12119,23 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -10357,10 +12199,20 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -10402,6 +12254,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} swap-case@1.1.2: @@ -10415,6 +12271,10 @@ snapshots: react: 19.2.1 use-sync-external-store: 1.6.0(react@19.2.1) + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + tailwind-merge@3.3.1: {} tailwindcss@4.1.11: {} @@ -10434,6 +12294,12 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -10477,6 +12343,8 @@ snapshots: dependencies: os-tmpdir: 1.0.2 + tmpl@1.0.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -10497,6 +12365,45 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)))(typescript@5.7.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.7.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) + jest-util: 30.2.0 + + ts-node@10.9.2(@types/node@20.19.25)(typescript@5.7.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.19.25 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.7.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + ts-node@10.9.2(@types/node@20.19.9)(typescript@5.9.2): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -10587,8 +12494,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + type-fest@0.21.3: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -10792,6 +12703,12 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + validate-npm-package-name@5.0.1: {} vfile-message@4.0.3: @@ -10899,6 +12816,10 @@ snapshots: - tsx - yaml + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -10969,12 +12890,43 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/turbo.json b/turbo.json index f69eabf..c3ae08d 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,9 @@ "ui": "tui", "globalDependencies": ["**/.env.*local"], "globalEnv": [ + "API_KEY_ENCRYPTION_SECRET", "CLERK_SECRET_KEY", + "CONVEX_SERVER_SHARED_SECRET", "CONVEX_DEPLOYMENT", "NEXT_PUBLIC_CONVEX_URL", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", From 198b9c656d4ed96c155e508a5dd077bc17e01db5 Mon Sep 17 00:00:00 2001 From: RYAN AKMAL PASYA Date: Tue, 17 Feb 2026 21:05:33 +0700 Subject: [PATCH 2/6] chore(tests): setup Jest environment with necessary mocks for Next.js and Clerk --- apps/web/package.json | 8 ++++++-- packages/eslint-config/base.js | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 3c61800..18fd440 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,10 @@ "start": "next start", "lint": "eslint .", "lint:fix": "eslint . --fix", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "jest --watch", + "test:once": "jest", + "test:coverage": "jest --coverage" }, "dependencies": { "@ai-sdk/google": "^3.0.18", @@ -33,7 +36,8 @@ "react-textarea-autosize": "^8.5.3", "recharts": "^3.7.0", "remark-gfm": "^4.0.1", - "sonner": "^2.0.7" + "sonner": "^2.0.7", + "zod": "^3.25.76" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.17", diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index b0f1eae..7665485 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -1,8 +1,8 @@ -import js from "@eslint/js" -import eslintConfigPrettier from "eslint-config-prettier" -import onlyWarn from "eslint-plugin-only-warn" -import turboPlugin from "eslint-plugin-turbo" -import tseslint from "typescript-eslint" +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import onlyWarn from "eslint-plugin-only-warn"; +import turboPlugin from "eslint-plugin-turbo"; +import tseslint from "typescript-eslint"; /** * A shared ESLint configuration for the repository. @@ -18,7 +18,12 @@ export const config = [ turbo: turboPlugin, }, rules: { - "turbo/no-undeclared-env-vars": "warn", + "turbo/no-undeclared-env-vars": [ + "warn", + { + allowList: ["^CONVEX_SERVER_SHARED_SECRET$"], + }, + ], }, }, { @@ -29,4 +34,4 @@ export const config = [ { ignores: ["dist/**"], }, -] +]; From e694b0f26d03df5dd9fa0c6d4cd99889901f4aab Mon Sep 17 00:00:00 2001 From: RYAN AKMAL PASYA Date: Tue, 17 Feb 2026 21:05:41 +0700 Subject: [PATCH 3/6] feat(backend): add security utilities and secret encryption --- packages/backend/convex/lib/security.ts | 530 ++++++++++++++++++++++++ packages/backend/convex/secrets.ts | 133 ++++++ 2 files changed, 663 insertions(+) create mode 100644 packages/backend/convex/lib/security.ts create mode 100644 packages/backend/convex/secrets.ts diff --git a/packages/backend/convex/lib/security.ts b/packages/backend/convex/lib/security.ts new file mode 100644 index 0000000..d1b03e4 --- /dev/null +++ b/packages/backend/convex/lib/security.ts @@ -0,0 +1,530 @@ +import type { GenericId } from "convex/values"; +import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server.js"; +import type { Doc, Id } from "../_generated/dataModel.js"; + +export type OrgRole = "owner" | "admin" | "member" | "viewer"; + +export type TenantContext = { + userId: string; + orgId?: string; + orgRole?: OrgRole; +}; + +export type AuditStatus = "success" | "denied" | "error"; + +const DEFAULT_VISITOR_SESSION_TTL_MS = 24 * 60 * 60 * 1000; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function fnv1aHex(value: string): string { + // FNV-1a 32-bit hash to avoid Node crypto dependency in Convex runtimes. + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +function isLocalhostHostname(hostname: string): boolean { + return hostname === "localhost"; +} + +function getDomainHashForStorage(normalizedHostname: string): string { + // Development convenience: allow tokens scoped to localhost without strict hashing. + // This makes local dev resilient to differing schemes/ports. + if (isLocalhostHostname(normalizedHostname)) { + return "localhost"; + } + return hashDomainToHex16(normalizedHostname); +} + +function isLocalhostDomainHash(domainHash: string): boolean { + return domainHash === "localhost"; +} + +export function hashDomainToHex16(domain: string): string { + // 16-hex chars by concatenating two hashes with different salts. + const a = fnv1aHex(`d1:${domain}`); + const b = fnv1aHex(`d2:${domain}`); + return `${a}${b}`; +} + +export function normalizeDomain(input: string): string { + const trimmed = input.trim().toLowerCase(); + // Accept either "example.com" or "https://example.com". + const asUrl = trimmed.includes("://") ? trimmed : `https://${trimmed}`; + const url = new URL(asUrl); + return url.hostname; +} + +export function generateOpaqueToken(): string { + const cryptoAny = globalThis.crypto as unknown as + | { + randomUUID?: () => string; + getRandomValues?: (a: Uint8Array) => Uint8Array; + } + | undefined; + + if (cryptoAny?.randomUUID) { + return cryptoAny.randomUUID(); + } + + if (cryptoAny?.getRandomValues) { + const bytes = cryptoAny.getRandomValues(new Uint8Array(32)); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + // Fallback: best-effort opaque value. (Foundation only; replace with crypto in prod.) + return `${Date.now()}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`; +} + +export async function requireIdentity( + ctx: QueryCtx | MutationCtx | ActionCtx, +): Promise<{ userId: string; orgId?: string }> { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Unauthorized: Must be logged in"); + } + + const orgId = (identity.org_id as string | undefined) || undefined; + return { userId: identity.subject, orgId }; +} + +export async function getTenantContext( + ctx: QueryCtx | MutationCtx, +): Promise { + const { userId, orgId } = await requireIdentity(ctx); + if (!orgId) { + return { userId }; + } + + const membership = await ctx.db + .query("orgMembers") + .withIndex("by_org_user", (q) => + q.eq("organization_id", orgId).eq("user_id", userId), + ) + .first(); + + const disabled = Boolean(membership?.disabled); + if (!membership || disabled) { + return { userId, orgId }; + } + + return { + userId, + orgId, + orgRole: membership.role, + }; +} + +export function canAccessResource( + resource: { user_id?: string; organization_id?: string } | null | undefined, + tenant: TenantContext, +): boolean { + if (!resource) return false; + + if (resource.user_id && resource.user_id === tenant.userId) { + return true; + } + + if ( + tenant.orgId && + tenant.orgRole && + resource.organization_id && + resource.organization_id === tenant.orgId + ) { + return true; + } + + return false; +} + +export function assertCanAccessResource< + T extends { user_id?: string; organization_id?: string }, +>( + resource: T | null | undefined, + tenant: TenantContext, + errorMessage = "Unauthorized", +): asserts resource is T { + if (!canAccessResource(resource, tenant)) { + throw new Error(errorMessage); + } +} + +export function assertIsOwner( + resource: { user_id?: string } | null | undefined, + tenant: TenantContext, + errorMessage = "Unauthorized: Not owner", +) { + if (!resource || resource.user_id !== tenant.userId) { + throw new Error(errorMessage); + } +} + +export function assertOrgAdmin( + tenant: TenantContext, + errorMessage = "Unauthorized: Must be org admin", +) { + const role = tenant.orgRole; + if (!tenant.orgId || !role || (role !== "owner" && role !== "admin")) { + throw new Error(errorMessage); + } +} + +export function redactSecrets(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => redactSecrets(item)) as unknown as T; + } + if (!isRecord(value)) return value; + + const result: Record = { ...value }; + + const sensitiveKeys = new Set([ + "api_key", + "_encrypted_api_key", + "authorization", + "auth_token", + "token", + "session_token", + "serverSecret", + "secret", + "password", + ]); + + for (const [key, val] of Object.entries(result)) { + if (sensitiveKeys.has(key)) { + result[key] = val ? "***REDACTED***" : val; + continue; + } + if (isRecord(val) || Array.isArray(val)) { + result[key] = redactSecrets(val); + } + } + + return result as T; +} + +export async function logAudit( + ctx: MutationCtx, + entry: { + user_id: string; + organization_id?: string; + action: string; + resource_type: string; + resource_id?: string; + status: AuditStatus; + error_message?: string; + changes?: { before: unknown; after: unknown }; + ip_address?: string; + user_agent?: string; + timestamp?: number; + }, +): Promise> { + const timestamp = entry.timestamp ?? Date.now(); + + return await ctx.db.insert("auditLogs", { + user_id: entry.user_id, + organization_id: entry.organization_id, + action: entry.action, + resource_type: entry.resource_type, + resource_id: entry.resource_id, + status: entry.status, + error_message: entry.error_message, + changes: entry.changes + ? { + before: redactSecrets(entry.changes.before), + after: redactSecrets(entry.changes.after), + } + : undefined, + ip_address: entry.ip_address, + user_agent: entry.user_agent, + timestamp, + }); +} + +export async function requireValidVisitorSession( + ctx: QueryCtx | MutationCtx, + args: { + sessionToken: string; + now?: number; + }, +): Promise> { + const now = args.now ?? Date.now(); + + const session = await ctx.db + .query("visitorSessions") + .withIndex("by_token", (q) => q.eq("session_token", args.sessionToken)) + .first(); + + if (!session) { + throw new Error("Invalid session token"); + } + + if (session.revoked) { + throw new Error("Session revoked"); + } + + if (session.expires_at < now) { + throw new Error("Session expired"); + } + + return session; +} + +export async function createVisitorSession( + ctx: MutationCtx, + args: { + botId: Id<"botProfiles">; + visitorId: string; + ttlMs?: number; + ip_address?: string; + user_agent_hash?: string; + now?: number; + }, +): Promise<{ sessionToken: string; expiresAt: number }> { + const now = args.now ?? Date.now(); + const ttlMs = args.ttlMs ?? DEFAULT_VISITOR_SESSION_TTL_MS; + const expiresAt = now + ttlMs; + + const token = generateOpaqueToken(); + + await ctx.db.insert("visitorSessions", { + visitor_id: args.visitorId, + bot_id: args.botId, + session_token: token, + created_at: now, + expires_at: expiresAt, + revoked: false, + ip_address: args.ip_address, + user_agent_hash: args.user_agent_hash, + }); + + return { sessionToken: token, expiresAt }; +} + +export async function assertConversationOwnedByVisitorSession( + ctx: QueryCtx | MutationCtx, + args: { + conversation: Doc<"conversations">; + session: Doc<"visitorSessions">; + }, +) { + if (args.conversation.bot_id !== args.session.bot_id) { + throw new Error("Unauthorized: Wrong bot"); + } + if ( + !args.conversation.visitor_id || + args.conversation.visitor_id !== args.session.visitor_id + ) { + throw new Error("Unauthorized: Wrong visitor"); + } +} + +export async function countRecentMessagesInConversation( + ctx: QueryCtx, + args: { + conversationId: Id<"conversations">; + sinceMs: number; + }, +): Promise { + const messages = await ctx.db + .query("messages") + .withIndex("by_conversation", (q) => + q.eq("conversation_id", args.conversationId), + ) + .collect(); + + return messages.filter((m) => m.created_at >= args.sinceMs).length; +} + +export async function assertRateLimitMessagesPerWindow( + ctx: QueryCtx, + args: { + conversationId: Id<"conversations">; + limit: number; + windowMs: number; + now?: number; + errorMessage?: string; + }, +) { + const now = args.now ?? Date.now(); + const sinceMs = now - args.windowMs; + const count = await countRecentMessagesInConversation(ctx, { + conversationId: args.conversationId, + sinceMs, + }); + + if (count >= args.limit) { + throw new Error(args.errorMessage ?? "Rate limited"); + } +} + +export async function requireValidEmbedToken( + ctx: QueryCtx | MutationCtx, + args: { + token: string; + currentDomain?: string; + now?: number; + }, +): Promise> { + const now = args.now ?? Date.now(); + + const embedToken = await ctx.db + .query("embedTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (!embedToken) { + throw new Error("Invalid token"); + } + + if (embedToken.revoked) { + throw new Error("Token revoked"); + } + + if (embedToken.expires_at < now) { + throw new Error("Token expired"); + } + + // Domain enforcement: + // - For localhost-scoped tokens, allow missing/localhost currentDomain. + // - For non-local tokens, require currentDomain and compare hash. + if (!args.currentDomain) { + if (!isLocalhostDomainHash(embedToken.domain_hash)) { + throw new Error("Origin required"); + } + return embedToken; + } + + const normalized = normalizeDomain(args.currentDomain); + if (isLocalhostDomainHash(embedToken.domain_hash)) { + if (!isLocalhostHostname(normalized)) { + throw new Error("Domain mismatch"); + } + return embedToken; + } + + const currentHash = hashDomainToHex16(normalized); + if (currentHash !== embedToken.domain_hash) { + throw new Error("Domain mismatch"); + } + + return embedToken; +} + +export async function requireBotProfile( + ctx: QueryCtx | MutationCtx, + botId: Id<"botProfiles">, +): Promise> { + const bot = await ctx.db.get(botId); + if (!bot) { + throw new Error("Bot not found"); + } + return bot; +} + +export function toPublicBotProfile(bot: Doc<"botProfiles">): { + _id: Id<"botProfiles">; + avatar_url: string; + bot_names: string; + bot_description: string; + msg_placeholder: string; + primary_color: string; + font: string; + theme_mode: string; + header_style: string; + message_style: string; + corner_radius: number; + enable_feedback: boolean; + enable_file_upload: boolean; + enable_sound: boolean; + history_reset: string; +} { + // Intentionally excludes: api_key, _encrypted_api_key, model settings, prompts, embed token metadata. + return { + _id: bot._id, + avatar_url: bot.avatar_url, + bot_names: bot.bot_names, + bot_description: bot.bot_description, + msg_placeholder: bot.msg_placeholder, + primary_color: bot.primary_color, + font: bot.font, + theme_mode: bot.theme_mode, + header_style: bot.header_style, + message_style: bot.message_style, + corner_radius: bot.corner_radius, + enable_feedback: bot.enable_feedback, + enable_file_upload: bot.enable_file_upload, + enable_sound: bot.enable_sound, + history_reset: bot.history_reset, + }; +} + +export function redactBotProfileSecrets(bot: Doc<"botProfiles">): Omit< + Doc<"botProfiles">, + "api_key" | "_encrypted_api_key" +> & { + api_key?: "***REDACTED***" | null; + _encrypted_api_key?: "***REDACTED***"; +} { + const rest = { ...bot } as Record; + if ("api_key" in rest) + rest.api_key = rest.api_key ? "***REDACTED***" : rest.api_key; + if ("_encrypted_api_key" in rest) { + rest._encrypted_api_key = rest._encrypted_api_key + ? "***REDACTED***" + : rest._encrypted_api_key; + } + return rest as any; +} + +export async function createEmbedToken( + ctx: MutationCtx, + args: { + bot: Doc<"botProfiles">; + domain: string; + expiresAt: number; + now?: number; + }, +): Promise<{ + token: string; + domain: string; + domainHash: string; + id: Id<"embedTokens">; +}> { + const now = args.now ?? Date.now(); + const domain = normalizeDomain(args.domain); + const domainHash = getDomainHashForStorage(domain); + + const token = generateOpaqueToken(); + const id = await ctx.db.insert("embedTokens", { + bot_id: args.bot._id, + user_id: args.bot.user_id, + organization_id: args.bot.organization_id, + token, + domain_hash: domainHash, + domain, + created_at: now, + expires_at: args.expiresAt, + revoked: false, + requests_today: 0, + last_request: undefined, + }); + + return { token, domain, domainHash, id }; +} + +export function assertDocIdEquals>( + a: T, + b: T, + errorMessage = "Unauthorized", +) { + if (a !== b) { + throw new Error(errorMessage); + } +} diff --git a/packages/backend/convex/secrets.ts b/packages/backend/convex/secrets.ts new file mode 100644 index 0000000..ecb3b0e --- /dev/null +++ b/packages/backend/convex/secrets.ts @@ -0,0 +1,133 @@ +const ENCRYPTED_PREFIX = "enc:v1:"; + +function bytesToBase64(bytes: Uint8Array): string { + // Node + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + + // Edge / browser + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCharCode(byte); + } + } + // eslint-disable-next-line no-undef + return btoa(binary); +} + +function base64ToBytes(base64: string): Uint8Array { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(base64, "base64")); + } + + // eslint-disable-next-line no-undef + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +async function deriveAesKey(secret: string): Promise { + const encoded = new TextEncoder().encode(secret); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return await crypto.subtle.importKey( + "raw", + digest, + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"], + ); +} + +export function isEncryptedSecret(value: string | null | undefined): boolean { + return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX); +} + +/** + * Encrypt a secret string for storage. + * + * If `API_KEY_ENCRYPTION_SECRET` is not set, returns the input unchanged. + */ +export async function encryptSecretForStorage( + plaintext: string, +): Promise { + const secret = process.env.API_KEY_ENCRYPTION_SECRET; + if (!secret) { + console.warn( + "[secrets] API_KEY_ENCRYPTION_SECRET not set; storing secret in plaintext.", + ); + return plaintext; + } + + if (isEncryptedSecret(plaintext)) { + return plaintext; + } + + const key = await deriveAesKey(secret); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded), + ); + + return `${ENCRYPTED_PREFIX}${bytesToBase64(iv)}:${bytesToBase64(ciphertext)}`; +} + +/** + * Decrypt a stored secret. + * + * - If the value is plaintext (no prefix), returns it unchanged. + * - If encrypted but `API_KEY_ENCRYPTION_SECRET` is missing, returns null. + */ +export async function decryptSecretFromStorage( + stored: string | null | undefined, +): Promise { + if (!stored) return null; + if (!isEncryptedSecret(stored)) return stored; + + const secret = process.env.API_KEY_ENCRYPTION_SECRET; + if (!secret) { + console.error( + "[secrets] Encrypted secret present but API_KEY_ENCRYPTION_SECRET is not set.", + ); + return null; + } + + const payload = stored.slice(ENCRYPTED_PREFIX.length); + const [ivB64, cipherB64] = payload.split(":"); + if (!ivB64 || !cipherB64) { + console.error("[secrets] Encrypted secret payload is malformed."); + return null; + } + + const key = await deriveAesKey(secret); + const iv = base64ToBytes(ivB64); + const ciphertext = base64ToBytes(cipherB64); + + try { + const plaintextBytes = new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(ciphertext), + ), + ); + return new TextDecoder().decode(plaintextBytes); + } catch { + console.error("[secrets] Failed to decrypt secret."); + return null; + } +} From 27bfa9f09e494a8c8427ae39f688cbfdb6c6aa5e Mon Sep 17 00:00:00 2001 From: RYAN AKMAL PASYA Date: Tue, 17 Feb 2026 21:05:41 +0700 Subject: [PATCH 4/6] feat(backend): implement secure embed token generation and validation --- packages/backend/convex/embedTokens.ts | 47 +++++++ .../functions/public/validateEmbedToken.ts | 52 ++++++++ packages/backend/convex/schema.ts | 118 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 packages/backend/convex/embedTokens.ts create mode 100644 packages/backend/convex/functions/public/validateEmbedToken.ts diff --git a/packages/backend/convex/embedTokens.ts b/packages/backend/convex/embedTokens.ts new file mode 100644 index 0000000..3fda22b --- /dev/null +++ b/packages/backend/convex/embedTokens.ts @@ -0,0 +1,47 @@ +import { v } from "convex/values"; +import { mutation } from "./_generated/server.js"; +import { + createEmbedToken, + requireIdentity, + requireBotProfile, +} from "./lib/security.js"; + +const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; + +/** + * AUTHENTICATED MUTATION: Generate a secure embed token scoped to a domain. + * + * - Validates the caller owns the bot. + * - Accepts domains like "example.com" or "localhost:3000". + * - Special-cases localhost for development. + */ +export const generateEmbedToken = mutation({ + args: { + botId: v.id("botProfiles"), + domain: v.string(), + }, + handler: async (ctx, args) => { + const { userId } = await requireIdentity(ctx); + const bot = await requireBotProfile(ctx, args.botId); + if (bot.user_id !== userId) { + throw new Error("Unauthorized: Not bot owner"); + } + + const now = Date.now(); + const expiresAt = now + ONE_YEAR_MS; + + const created = await createEmbedToken(ctx, { + bot, + domain: args.domain, + expiresAt, + now, + }); + + return { + token: created.token, + domain: created.domain, + domainHash: created.domainHash, + expiresAt, + }; + }, +}); diff --git a/packages/backend/convex/functions/public/validateEmbedToken.ts b/packages/backend/convex/functions/public/validateEmbedToken.ts new file mode 100644 index 0000000..05709c2 --- /dev/null +++ b/packages/backend/convex/functions/public/validateEmbedToken.ts @@ -0,0 +1,52 @@ +import { v } from "convex/values"; +import { query } from "../../_generated/server.js"; +import { + requireValidEmbedToken, + requireBotProfile, +} from "../../lib/security.js"; + +/** + * PUBLIC QUERY: Validate an embed token and return ONLY public bot config. + * + * `currentDomain` should be derived from an unspoofable browser source when possible + * (e.g. `document.referrer` hostname from inside the widget iframe). + */ +export const validateEmbedToken = query({ + args: { + token: v.string(), + currentDomain: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const embedToken = await requireValidEmbedToken(ctx, { + token: args.token, + currentDomain: args.currentDomain, + }); + + const botProfile = await requireBotProfile(ctx, embedToken.bot_id); + + return { + id: botProfile._id, + organizationId: botProfile.organization_id, + profile: { + displayName: botProfile.bot_names, + description: botProfile.bot_description, + placeholder: botProfile.msg_placeholder, + avatarUrl: botProfile.avatar_url, + }, + appearance: { + primaryColor: botProfile.primary_color, + font: botProfile.font, + themeMode: botProfile.theme_mode, + cornerRadius: botProfile.corner_radius, + headerStyle: botProfile.header_style, + messageStyle: botProfile.message_style, + }, + features: { + enableFeedback: botProfile.enable_feedback, + enableFileUpload: botProfile.enable_file_upload, + enableSound: botProfile.enable_sound, + enableMarkdown: true, + }, + }; + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 4e35d31..4c5511a 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -32,6 +32,9 @@ export default defineSchema({ model_provider: v.optional(v.union(v.string(), v.null())), model_id: v.optional(v.union(v.string(), v.null())), api_key: v.optional(v.union(v.string(), v.null())), + // NOTE: Foundation for secret-safe storage (do not return to clients) + // Keep `api_key` for backward compatibility during migration. + _encrypted_api_key: v.optional(v.string()), system_prompt: v.optional(v.string()), temperature: v.optional(v.number()), max_tokens: v.optional(v.number()), @@ -169,6 +172,7 @@ export default defineSchema({ aiLogs: defineTable({ // ✅ Multi-tenancy: Isolate logs by bot owner user_id: v.optional(v.string()), // Clerk user ID (bot owner) + organization_id: v.optional(v.string()), // Clerk organization ID (if org member) botId: v.id("botProfiles"), conversationId: v.id("conversations"), @@ -183,6 +187,16 @@ export default defineSchema({ success: v.boolean(), errorMessage: v.optional(v.string()), integration: v.string(), // "playground", "emulator", etc. + + // --- NEW: Tool Calls & Token Usage (Production Debugging/Billing) --- + // Store raw tool calls emitted by the model (AI SDK steps[].toolCalls) + toolCalls: v.optional(v.array(v.any())), + + // Token accounting (if provider returns usage) + promptTokens: v.optional(v.number()), + completionTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + createdAt: v.number(), }) .index("by_botId", ["botId"]) @@ -238,4 +252,108 @@ export default defineSchema({ .index("by_bot_createdAt", ["botId", "createdAt"]) .index("by_org_createdAt", ["organizationId", "createdAt"]) .index("by_dedupeKey", ["dedupeKey"]), + + // === Security Foundations (Multi-tenant hardening) === + + /** + * Gap #1: Visitor Session Validation + * Token-based visitor sessions scoped to (visitor_id, bot_id) with TTL. + */ + visitorSessions: defineTable({ + visitor_id: v.string(), + bot_id: v.id("botProfiles"), + session_token: v.string(), + created_at: v.number(), + expires_at: v.number(), + revoked: v.optional(v.boolean()), + revoked_at: v.optional(v.number()), + ip_address: v.optional(v.string()), + user_agent_hash: v.optional(v.string()), + }) + .index("by_token", ["session_token"]) + .index("by_visitor_bot", ["visitor_id", "bot_id"]) + .index("by_bot", ["bot_id"]) + .index("by_expires", ["expires_at"]), + + /** + * Gap #3: Organization-level Isolation + * Explicit org membership table for role-based access checks. + */ + orgMembers: defineTable({ + organization_id: v.string(), + user_id: v.string(), + role: v.union( + v.literal("owner"), + v.literal("admin"), + v.literal("member"), + v.literal("viewer"), + ), + joined_at: v.number(), + invited_by: v.optional(v.string()), + disabled: v.optional(v.boolean()), + disabled_at: v.optional(v.number()), + }) + .index("by_org", ["organization_id"]) + .index("by_user", ["user_id"]) + .index("by_org_user", ["organization_id", "user_id"]), + + /** + * Gap #4: Missing Audit Trail Logging + * Central audit log table for sensitive access and mutations. + */ + auditLogs: defineTable({ + user_id: v.string(), + organization_id: v.optional(v.string()), + + action: v.string(), + resource_type: v.string(), + resource_id: v.optional(v.string()), + + changes: v.optional( + v.object({ + before: v.any(), + after: v.any(), + }), + ), + + status: v.union( + v.literal("success"), + v.literal("denied"), + v.literal("error"), + ), + error_message: v.optional(v.string()), + + ip_address: v.optional(v.string()), + user_agent: v.optional(v.string()), + timestamp: v.number(), + }) + .index("by_user", ["user_id"]) + .index("by_org", ["organization_id"]) + .index("by_resource", ["resource_type", "resource_id"]) + .index("by_timestamp", ["timestamp"]), + + /** + * Gap #5: Widget Embedding Security + * Domain-scoped embed tokens to avoid bot enumeration and enforce allowlisting. + */ + embedTokens: defineTable({ + bot_id: v.id("botProfiles"), + user_id: v.string(), + organization_id: v.optional(v.string()), + + token: v.string(), + domain_hash: v.string(), + domain: v.optional(v.string()), + + created_at: v.number(), + expires_at: v.number(), + revoked: v.boolean(), + revoked_at: v.optional(v.number()), + + requests_today: v.number(), + last_request: v.optional(v.number()), + }) + .index("by_token", ["token"]) + .index("by_bot", ["bot_id"]) + .index("by_user", ["user_id"]), }); From 93dd1abd9ff91238c9a006a1fc03d86045b2eba9 Mon Sep 17 00:00:00 2001 From: RYAN AKMAL PASYA Date: Tue, 17 Feb 2026 21:05:45 +0700 Subject: [PATCH 5/6] feat(tests): add unit tests for Clerk authentication logic using useAuth hook --- apps/web/__tests__/auth-convex.test.tsx | 318 ++++++++++++++++++++ apps/web/__tests__/auth-flow.test.tsx | 299 +++++++++++++++++++ apps/web/__tests__/auth-simple.test.ts | 367 ++++++++++++++++++++++++ apps/web/__tests__/auth.test.tsx | 275 ++++++++++++++++++ 4 files changed, 1259 insertions(+) create mode 100644 apps/web/__tests__/auth-convex.test.tsx create mode 100644 apps/web/__tests__/auth-flow.test.tsx create mode 100644 apps/web/__tests__/auth-simple.test.ts create mode 100644 apps/web/__tests__/auth.test.tsx diff --git a/apps/web/__tests__/auth-convex.test.tsx b/apps/web/__tests__/auth-convex.test.tsx new file mode 100644 index 0000000..c7391ac --- /dev/null +++ b/apps/web/__tests__/auth-convex.test.tsx @@ -0,0 +1,318 @@ +import { renderHook } from "@testing-library/react"; +import { useAuth } from "@clerk/nextjs"; + +jest.mock("@clerk/nextjs"); + +describe("Authentication with Convex Integration", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Clerk Authentication for Convex", () => { + it("should provide authentication token for Convex API calls", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockToken = "convex_authenticated_token"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "clerk_user_123", + sessionId: "clerk_session_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(mockToken), + } as any); + + const { result } = renderHook(() => useAuth()); + + const token = await result.current.getToken(); + expect(token).toBe(mockToken); + }); + + it("should handle Convex query authentication", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "session_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue("auth_token"), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isSignedIn).toBe(true); + expect(result.current.getToken).toBeDefined(); + }); + + it("should prevent unauthorized API access", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn().mockRejectedValue(new Error("Not authenticated")), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isSignedIn).toBe(false); + }); + }); + + describe("User Identity in Convex Context", () => { + it("should provide user identity for database operations", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const userId = "convex_user_456"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId, + sessionId: "convex_session_456", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + // User identity should be available for Convex queries + expect(result.current.userId).toBe(userId); + }); + + it("should handle missing user identity", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.userId).toBeNull(); + }); + }); + + describe("Session Validation", () => { + it("should validate active session for Convex operations", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_789", + sessionId: "active_session_789", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + const isSessionValid = + result.current.isLoaded && + result.current.isSignedIn && + result.current.sessionId !== null; + + expect(isSessionValid).toBe(true); + }); + + it("should detect invalid or expired sessions", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn().mockRejectedValue(new Error("Session expired")), + } as any); + + const { result } = renderHook(() => useAuth()); + + const isSessionValid = + result.current.isLoaded && + result.current.isSignedIn && + result.current.sessionId !== null; + + expect(isSessionValid).toBe(false); + }); + }); + + describe("Secure API Communication", () => { + it("should include authentication headers in API calls", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockToken = "secure_api_token"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "api_user_123", + sessionId: "api_session_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(mockToken), + } as any); + + const { result } = renderHook(() => useAuth()); + + const token = await result.current.getToken(); + + // Simulate API header construction + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + expect(headers.Authorization).toBe(`Bearer ${mockToken}`); + }); + + it("should handle token refresh", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const oldToken = "old_token"; + const newToken = "refreshed_token"; + + // First call returns old token + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_refresh", + sessionId: "session_refresh", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(oldToken), + } as any); + + const { result: result1 } = renderHook(() => useAuth()); + const token1 = await result1.current.getToken(); + + // Second call returns new token + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_refresh", + sessionId: "session_refresh", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(newToken), + } as any); + + const { result: result2 } = renderHook(() => useAuth()); + const token2 = await result2.current.getToken(); + + expect(token1).toBe(oldToken); + expect(token2).toBe(newToken); + expect(token1).not.toBe(token2); + }); + }); + + describe("Error Handling in Auth Integration", () => { + it("should handle authentication errors gracefully", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const authError = new Error("Authentication failed"); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn().mockRejectedValue(authError), + } as any); + + const { result } = renderHook(() => useAuth()); + + try { + await result.current.getToken(); + } catch (error) { + expect(error).toBe(authError); + } + }); + + it("should retry failed authentication attempts", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + let attemptCount = 0; + + const mockGetToken = jest.fn(async () => { + attemptCount = attemptCount + 1; + if (attemptCount < 2) { + throw new Error("First attempt failed"); + } + return "successful_token"; + }); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "retry_user", + sessionId: "retry_session", + signOut: jest.fn(), + getToken: mockGetToken, + } as any); + + const { result } = renderHook(() => useAuth()); + + // First attempt fails + try { + await result.current.getToken(); + } catch { + // Expected + } + + // Second attempt succeeds + const token = await result.current.getToken(); + expect(token).toBe("successful_token"); + }); + }); + + describe("Multi-User Scenarios", () => { + it("should handle switching between users", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // User 1 authenticates + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_1", + sessionId: "session_1", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: user1Result } = renderHook(() => useAuth()); + expect(user1Result.current.userId).toBe("user_1"); + + // User 1 signs out + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: signedOutResult } = renderHook(() => useAuth()); + expect(signedOutResult.current.userId).toBeNull(); + + // User 2 authenticates + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_2", + sessionId: "session_2", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: user2Result } = renderHook(() => useAuth()); + expect(user2Result.current.userId).toBe("user_2"); + }); + }); +}); diff --git a/apps/web/__tests__/auth-flow.test.tsx b/apps/web/__tests__/auth-flow.test.tsx new file mode 100644 index 0000000..68abdad --- /dev/null +++ b/apps/web/__tests__/auth-flow.test.tsx @@ -0,0 +1,299 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { useAuth } from "@clerk/nextjs"; + +jest.mock("@clerk/nextjs"); + +// Simple test component that uses useAuth +const TestAuthComponent = () => { + const { isLoaded, isSignedIn, userId } = useAuth() as any; + + if (!isLoaded) { + return React.createElement("div", null, "Loading..."); + } + + if (!isSignedIn) { + return React.createElement("div", null, "Please sign in"); + } + + return React.createElement("div", null, `Welcome, ${userId}!`); +}; + +describe("Authentication Flow Components", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Login Status Display", () => { + it("should display loading state", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: false, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + render(React.createElement(TestAuthComponent)); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("should display sign in prompt when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + render(React.createElement(TestAuthComponent)); + + expect(screen.getByText("Please sign in")).toBeInTheDocument(); + }); + + it("should display welcome message when authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const testUserId = "user_123"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: testUserId, + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + render(React.createElement(TestAuthComponent)); + + expect(screen.getByText(`Welcome, ${testUserId}!`)).toBeInTheDocument(); + }); + }); + + describe("Authentication Persistence", () => { + it("should maintain authenticated state across renders", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "persistent_user", + sessionId: "persistent_sess", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { rerender } = render(React.createElement(TestAuthComponent)); + + expect(screen.getByText("Welcome, persistent_user!")).toBeInTheDocument(); + + // Re-render should maintain state + rerender(React.createElement(TestAuthComponent)); + + expect(screen.getByText("Welcome, persistent_user!")).toBeInTheDocument(); + }); + }); + + describe("User Identification", () => { + it("should correctly identify user ID when authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const testUserId = "user_xyz789"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: testUserId, + sessionId: "sess_abc", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + render(React.createElement(TestAuthComponent)); + + expect(screen.getByText(`Welcome, ${testUserId}!`)).toBeInTheDocument(); + }); + + it("should have null user ID when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + render(React.createElement(TestAuthComponent)); + + const welcomeElement = screen.queryByText(/Welcome/); + expect(welcomeElement).not.toBeInTheDocument(); + }); + }); +}); + +describe("Login Success Scenarios", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should successfully recognize completed login", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // Simulate successful login + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "successfully_logged_in_user", + sessionId: "valid_session", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue("valid_token"), + } as any); + + const scenario = { + loginAttempt: true, + authResult: mockUseAuth(), + }; + + expect(scenario.loginAttempt).toBe(true); + expect(scenario.authResult.isSignedIn).toBe(true); + expect(scenario.authResult.userId).toBeDefined(); + expect(scenario.authResult.sessionId).toBeDefined(); + }); + + it("should reject invalid login attempts", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // Simulate failed login + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const scenario = { + loginAttempt: true, + authResult: mockUseAuth(), + }; + + expect(scenario.loginAttempt).toBe(true); + expect(scenario.authResult.isSignedIn).toBe(false); + expect(scenario.authResult.userId).toBeNull(); + expect(scenario.authResult.sessionId).toBeNull(); + }); + + it("should handle concurrent login requests", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + const loginPromises = Array(3) + .fill(null) + .map(() => { + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "concurrent_user", + sessionId: `session_${Math.random()}`, + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue("token"), + } as any); + + return mockUseAuth(); + }); + + expect(loginPromises).toHaveLength(3); + expect(loginPromises.every((result) => result.isSignedIn)).toBe(true); + }); + + it("should provide valid token for authenticated users", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockToken = "valid_jwt_token_12345"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_with_token", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(mockToken), + } as any); + + const authState = mockUseAuth(); + const token = await authState.getToken(); + + expect(token).toBe(mockToken); + }); + + it("should validate login state consistency", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const authState = mockUseAuth(); + + // Validate consistency: if signed in, should have userId and sessionId + if (authState.isSignedIn) { + expect(authState.userId).not.toBeNull(); + expect(authState.sessionId).not.toBeNull(); + } + }); +}); + +describe("Login Protection", () => { + it("should ensure isLoaded check prevents race conditions", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, // Important: always check this before using other values + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const authState = mockUseAuth(); + + // This pattern prevents using auth values before they're loaded + if (authState.isLoaded && authState.isSignedIn) { + expect(authState.userId).toBeDefined(); + } + }); + + it("should handle missing session gracefully", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const authState = mockUseAuth(); + + // Should not throw errors when accessing null properties + expect(() => { + if (authState.isSignedIn) { + // This won't execute + authState.userId?.toString(); + } + }).not.toThrow(); + }); +}); diff --git a/apps/web/__tests__/auth-simple.test.ts b/apps/web/__tests__/auth-simple.test.ts new file mode 100644 index 0000000..f0b43e7 --- /dev/null +++ b/apps/web/__tests__/auth-simple.test.ts @@ -0,0 +1,367 @@ +/* __tests__/auth.test.ts */ + +import { useAuth } from "@clerk/nextjs"; + +jest.mock("@clerk/nextjs"); + +describe("Clerk Authentication - Login Logic", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("useAuth Hook", () => { + it("should return loading state initially", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: false, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(auth.isLoaded).toBe(false); + }); + + it("should indicate signed out state when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(auth.isLoaded).toBe(true); + expect(auth.isSignedIn).toBe(false); + expect(auth.userId).toBeNull(); + }); + + it("should indicate signed in state with user ID when authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockUserId = "user_123"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: mockUserId, + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue("mock_token"), + } as any); + + const auth = useAuth(); + expect(auth.isLoaded).toBe(true); + expect(auth.isSignedIn).toBe(true); + expect(auth.userId).toBe(mockUserId); + expect(auth.sessionId).toBe("sess_123"); + }); + + it("should provide signOut method", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockSignOut = jest.fn().mockResolvedValue(undefined); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: mockSignOut, + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(typeof auth.signOut).toBe("function"); + }); + + it("should provide getToken method for API authentication", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockGetToken = jest.fn().mockResolvedValue("mock_jwt_token"); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: mockGetToken, + } as any); + + const auth = useAuth(); + expect(typeof auth.getToken).toBe("function"); + }); + }); + + describe("Authentication State Transitions", () => { + it("should transition from loading to authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // Initial loading state + mockUseAuth.mockReturnValueOnce({ + isLoaded: false, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + let auth = useAuth(); + expect(auth.isLoaded).toBe(false); + + // After authentication + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + auth = useAuth(); + expect(auth.isLoaded).toBe(true); + expect(auth.isSignedIn).toBe(true); + }); + + it("should transition from authenticated to signed out", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // Authenticated state + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + let auth = useAuth(); + expect(auth.isSignedIn).toBe(true); + + // After sign out + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + auth = useAuth(); + expect(auth.isSignedIn).toBe(false); + expect(auth.userId).toBeNull(); + }); + }); + + describe("Error Handling", () => { + it("should handle getToken errors gracefully", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockGetToken = jest + .fn() + .mockRejectedValue(new Error("Token fetch failed")); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: mockGetToken, + } as any); + + const auth = useAuth(); + expect(typeof auth.getToken).toBe("function"); + }); + + it("should handle signOut errors gracefully", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockSignOut = jest + .fn() + .mockRejectedValue(new Error("Sign out failed")); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: mockSignOut, + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(typeof auth.signOut).toBe("function"); + }); + }); + + describe("Session Management", () => { + it("should have session ID when authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_456", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(auth.sessionId).toBe("sess_456"); + }); + + it("should have null session ID when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(auth.sessionId).toBeNull(); + }); + }); + + describe("Login Success Verification", () => { + it("should successfully recognize completed login", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "successfully_logged_in_user", + sessionId: "valid_session", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue("valid_token"), + } as any); + + const auth = useAuth(); + expect(auth.isSignedIn).toBe(true); + expect(auth.userId).toBeDefined(); + expect(auth.sessionId).toBeDefined(); + }); + + it("should reject invalid login attempts", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(auth.isSignedIn).toBe(false); + expect(auth.userId).toBeNull(); + expect(auth.sessionId).toBeNull(); + }); + + it("should validate login state consistency", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + + // Check consistency + if (auth.isSignedIn) { + expect(auth.userId).not.toBeNull(); + expect(auth.sessionId).not.toBeNull(); + } + }); + + it("should provide valid token for authenticated users", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockToken = "valid_jwt_token_12345"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_with_token", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(mockToken), + } as any); + + const auth = useAuth(); + const token = await auth.getToken(); + expect(token).toBe(mockToken); + }); + }); + + describe("Convex Integration with Clerk Auth", () => { + it("should provide authentication token for Convex API", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockToken = "convex_auth_token"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "clerk_user_123", + sessionId: "clerk_session_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue(mockToken), + } as any); + + const auth = useAuth(); + const token = await auth.getToken(); + expect(token).toBe(mockToken); + }); + + it("should prevent unauthorized Convex access when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn().mockRejectedValue(new Error("Not authenticated")), + } as any); + + const auth = useAuth(); + expect(auth.isSignedIn).toBe(false); + }); + + it("should validate user identity for Convex database operations", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const userId = "user_for_db_ops"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId, + sessionId: "db_session", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const auth = useAuth(); + expect(auth.userId).toBe(userId); + }); + }); +}); diff --git a/apps/web/__tests__/auth.test.tsx b/apps/web/__tests__/auth.test.tsx new file mode 100644 index 0000000..a151d69 --- /dev/null +++ b/apps/web/__tests__/auth.test.tsx @@ -0,0 +1,275 @@ +import { renderHook } from "@testing-library/react"; +import { useAuth } from "@clerk/nextjs"; + +jest.mock("@clerk/nextjs"); + +describe("Clerk Authentication - Login Logic", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("useAuth Hook", () => { + it("should return loading state initially", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: false, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isLoaded).toBe(false); + }); + + it("should indicate signed out state when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.isSignedIn).toBe(false); + expect(result.current.userId).toBeNull(); + }); + + it("should indicate signed in state with user ID when authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockUserId = "user_123"; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: mockUserId, + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn().mockResolvedValue("mock_token"), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.isSignedIn).toBe(true); + expect(result.current.userId).toBe(mockUserId); + expect(result.current.sessionId).toBe("sess_123"); + }); + + it("should provide signOut method", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockSignOut = jest.fn().mockResolvedValue(undefined); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: mockSignOut, + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(typeof result.current.signOut).toBe("function"); + }); + + it("should provide getToken method for API authentication", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockGetToken = jest.fn().mockResolvedValue("mock_jwt_token"); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: mockGetToken, + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(typeof result.current.getToken).toBe("function"); + }); + + it("should handle multiple authentication checks", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // First check - not authenticated + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: result1 } = renderHook(() => useAuth()); + expect(result1.current.isSignedIn).toBe(false); + + // Second check - authenticated + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: result2 } = renderHook(() => useAuth()); + expect(result2.current.isSignedIn).toBe(true); + }); + }); + + describe("Authentication State Transitions", () => { + it("should transition from loading to authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // Initial loading state + mockUseAuth.mockReturnValueOnce({ + isLoaded: false, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: loadingResult } = renderHook(() => useAuth()); + expect(loadingResult.current.isLoaded).toBe(false); + + // After authentication + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: authResult } = renderHook(() => useAuth()); + expect(authResult.current.isLoaded).toBe(true); + expect(authResult.current.isSignedIn).toBe(true); + }); + + it("should transition from authenticated to signed out", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + // Authenticated state + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: authResult } = renderHook(() => useAuth()); + expect(authResult.current.isSignedIn).toBe(true); + + // After sign out + mockUseAuth.mockReturnValueOnce({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result: signedOutResult } = renderHook(() => useAuth()); + expect(signedOutResult.current.isSignedIn).toBe(false); + expect(signedOutResult.current.userId).toBeNull(); + }); + }); + + describe("Error Handling", () => { + it("should handle getToken errors gracefully", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockGetToken = jest + .fn() + .mockRejectedValue(new Error("Token fetch failed")); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: jest.fn(), + getToken: mockGetToken, + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(typeof result.current.getToken).toBe("function"); + }); + + it("should handle signOut errors gracefully", async () => { + const mockUseAuth = useAuth as jest.MockedFunction; + const mockSignOut = jest + .fn() + .mockRejectedValue(new Error("Sign out failed")); + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_123", + signOut: mockSignOut, + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(typeof result.current.signOut).toBe("function"); + }); + }); + + describe("Session Management", () => { + it("should have session ID when authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + userId: "user_123", + sessionId: "sess_456", + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.sessionId).toBe("sess_456"); + }); + + it("should have null session ID when not authenticated", () => { + const mockUseAuth = useAuth as jest.MockedFunction; + + mockUseAuth.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + signOut: jest.fn(), + getToken: jest.fn(), + } as any); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.sessionId).toBeNull(); + }); + }); +}); From 4e0b99a7bdcae57dc1391579e4a6c2b6d7501b1b Mon Sep 17 00:00:00 2001 From: RYAN AKMAL PASYA Date: Tue, 17 Feb 2026 21:06:25 +0700 Subject: [PATCH 6/6] feat(audit): add logging for various mutations and queries --- README.md | 250 ++++- apps/web/app/(auth)/signin/page.tsx | 247 +---- apps/web/app/(auth)/signup/page.tsx | 428 +-------- apps/web/app/api/chat/stream/route.ts | 199 +++- .../web/app/dashboard/configurations/page.tsx | 24 +- apps/web/app/dashboard/overview/page.tsx | 36 +- .../webchat/deploy-settings/page.tsx | 76 +- apps/web/app/page.tsx | 31 +- .../configurations/bot-emulator.tsx | 8 +- .../components/configurations/bot-sidebar.tsx | 8 +- .../configurations/dynamic-inspector.tsx | 43 +- .../configurations/kb-analytics.tsx | 261 +++++- .../configurations/kb-document-list.tsx | 10 +- .../website-scraper-handler.tsx | 607 ++++++++++-- apps/web/components/providers.tsx | 5 +- apps/web/components/webchat/bot-widget.tsx | 14 +- apps/web/contexts/webchat-context.tsx | 36 +- apps/web/lib/convex-client.ts | 35 +- apps/web/next-env.d.ts | 2 +- apps/web/proxy.ts | 10 +- apps/widget/app/widget/page.tsx | 91 +- apps/widget/public/embed.global.js | 10 +- apps/widget/src/embed.ts | 22 +- apps/widget/tsup.config.ts | 4 +- .../backend/__tests__/helpers/fixtures.ts | 14 +- .../integration/public-widget-session.test.ts | 100 -- packages/backend/convex/_generated/api.d.ts | 8 + packages/backend/convex/ai.ts | 864 ++++++++++++++++-- packages/backend/convex/aiAnalytics.ts | 108 ++- packages/backend/convex/analytics.ts | 8 +- packages/backend/convex/configuration.ts | 131 ++- .../convex/functions/public/createSession.ts | 177 ++-- .../convex/functions/public/endSession.ts | 69 +- .../convex/functions/public/generateReply.ts | 54 +- .../functions/public/generateReplyStream.ts | 47 +- .../convex/functions/public/getBotProfile.ts | 20 +- .../functions/public/getConversationStatus.ts | 30 +- .../convex/functions/public/getMessages.ts | 47 +- .../functions/public/getSessionDetails.ts | 78 +- .../backend/convex/functions/public/index.ts | 1 + .../convex/functions/public/sendMessage.ts | 135 +-- .../convex/functions/public/trackEvent.ts | 130 ++- packages/backend/convex/kbanalytics.ts | 110 ++- packages/backend/convex/knowledge.ts | 383 +++++++- packages/backend/convex/migrations.ts | 83 ++ packages/backend/convex/monitor.ts | 836 ++++++++++++++--- packages/backend/convex/playground.ts | 676 ++++++++++---- packages/backend/convex/public.ts | 1 + packages/backend/convex/testing.ts | 93 +- packages/backend/convex/webchat.ts | 293 ++++-- packages/backend/convex/websitescraper.ts | 396 +++++++- .../components/widget/chat/chat-container.tsx | 2 +- .../components/widget/chat/chat-messages.tsx | 2 +- .../components/widget/chat/message-bubble.tsx | 88 +- packages/ui/src/components/widget/types.ts | 4 +- 55 files changed, 5397 insertions(+), 2048 deletions(-) delete mode 100644 packages/backend/__tests__/integration/public-widget-session.test.ts diff --git a/README.md b/README.md index 607e1ce..714602c 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,245 @@ -# shadcn/ui monorepo template +# Chatify — App Features & Flow -This template is for creating a monorepo with shadcn/ui. +This document is a **single, skimmable overview** of what Chatify does, what features exist in this repo, and the main end-to-end flows (Admin → Configure → Deploy → Visitor chat → Monitor). -## Usage +--- -```bash -pnpm dlx shadcn@latest init +## 1) What is Chatify? + +Chatify is a **multi-tenant AI webchat platform**: + +- **Admins** use a dashboard to configure an AI agent (model/provider, prompt, knowledge base, appearance, features). +- **Customers** embed a lightweight script into any website to show a **chat bubble + chat panel**. +- **Visitors** can chat without login (public sessions) while the system stores conversations/messages and can stream AI responses. + +--- + +## 2) Repo structure (what each part is responsible for) + +- Dashboard app (Admin UI): + - [apps/web](apps/web) +- Widget runtime (embedded into external websites): + - [apps/widget](apps/widget) +- Backend (Convex schema + server functions): + - [packages/backend](packages/backend) +- Shared UI components (shadcn/radix + Tailwind tokens): + - [packages/ui](packages/ui) + +Key reference docs: + +- System overview: [SYSTEM_OVERVIEW.md](SYSTEM_OVERVIEW.md) +- Phase delivery summary: [PHASE-2-DELIVERY-SUMMARY.md](PHASE-2-DELIVERY-SUMMARY.md) +- Architecture audit: [ARCHITECTURE-AUDIT-SUMMARY.md](ARCHITECTURE-AUDIT-SUMMARY.md) + +--- + +## 3) Core features (what the app can do) + +### A) Admin dashboard features (apps/web) + +Dashboard routes live under [apps/web/app/dashboard](apps/web/app/dashboard): + +- Overview page (high-level landing) + - [apps/web/app/dashboard/overview](apps/web/app/dashboard/overview) +- Monitor (observe what’s happening) + - Conversations list/details: [apps/web/app/dashboard/monitor/conversations](apps/web/app/dashboard/monitor/conversations) + - Users list/details: [apps/web/app/dashboard/monitor/users](apps/web/app/dashboard/monitor/users) +- Configurations (agent “brain”) + - Model/provider selection + API key presence + - System prompt / instructions + - Knowledge base management and analytics + - Lead capture / escalation settings (WhatsApp/email) + - Main page: [apps/web/app/dashboard/configurations/page.tsx](apps/web/app/dashboard/configurations/page.tsx) +- Webchat (agent “face”) + - Bot profile: [apps/web/app/dashboard/webchat/bot-profile](apps/web/app/dashboard/webchat/bot-profile) + - Appearance (theme/font/header/message styles, radius): [apps/web/app/dashboard/webchat/bot-appearance](apps/web/app/dashboard/webchat/bot-appearance) + - Feature toggles (feedback/file upload/sound/etc): [apps/web/app/dashboard/webchat/features](apps/web/app/dashboard/webchat/features) + - Deploy settings (embed setup): [apps/web/app/dashboard/webchat/deploy-settings](apps/web/app/dashboard/webchat/deploy-settings) +- Integrations (currently UI surfaces / stubs) + - WhatsApp: [apps/web/app/dashboard/integrations/whatsapp](apps/web/app/dashboard/integrations/whatsapp) + - Instagram: [apps/web/app/dashboard/integrations/instagram](apps/web/app/dashboard/integrations/instagram) + - Omnichannel: [apps/web/app/dashboard/integrations/omnichannel](apps/web/app/dashboard/integrations/omnichannel) +- Settings (account and organization context) + - Profile: [apps/web/app/dashboard/settings/profile](apps/web/app/dashboard/settings/profile) + - Billing: [apps/web/app/dashboard/settings/billing](apps/web/app/dashboard/settings/billing) + - General: [apps/web/app/dashboard/settings/general](apps/web/app/dashboard/settings/general) + +Other useful admin tooling: + +- Widget demo/testing page: [apps/web/app/widget-demo](apps/web/app/widget-demo) +- Streaming chat API route (SSE): [apps/web/app/api/chat/stream/route.ts](apps/web/app/api/chat/stream/route.ts) + +### B) Embedded widget features (apps/widget) + +The widget has two parts: + +1. **Embed script** (what customers paste into their website) + +- Source: [apps/widget/src/embed.ts](apps/widget/src/embed.ts) +- Responsibilities: + - Read required attributes: `data-organization-id`, `data-bot-id` + - Create a fixed-position host with **0x0 layout impact** + - Use **Shadow DOM + defensive CSS** to avoid site CSS conflicts + - Create an **iframe** that loads the widget UI from the widget app + - Create and persist a `visitorId` in localStorage with TTL (resets daily UTC) + - Manage open/close state (bubble ↔ panel) + +2. **Widget UI app** (runs inside the iframe) + +- Lives in [apps/widget](apps/widget) (Next.js App Router) +- Responsibilities: + - Fetch bot configuration (name/color/etc) + - Create/validate a public chat session + - Send messages and display chat history + - Keep launcher/container theme in sync (via postMessage from iframe → embed) + +Key widget runtime entry point: + +- Widget page: [apps/widget/app/widget/page.tsx](apps/widget/app/widget/page.tsx) +- postMessage bridge: [apps/widget/lib/postmessage-bridge.ts](apps/widget/lib/postmessage-bridge.ts) + +### C) Backend/data features (packages/backend) + +Convex schema defines the main entities (packages/backend/convex/schema.ts): + +- Schema: [packages/backend/convex/schema.ts](packages/backend/convex/schema.ts) + +- botProfiles: bot config, model config, appearance, feature flags, embed token, escalation +- conversations: chat sessions (supports both authenticated participants and public visitors) +- messages: chat messages for each conversation +- documents: knowledge base chunks + embeddings + vector index +- aiLogs: AI response logs/metrics +- publicSessions: stateless public session validation for widget visitors +- businessEvents: lead-click events (WhatsApp/email) + +Knowledge base is designed for retrieval-augmented generation (RAG): + +- documents table stores `text` + `embedding[]` +- vector index: `by_embedding` (dimensions: 3072) + +--- + +## 4) Key user flows (end-to-end) + +### Flow 1 — Admin configures a bot + +1. Admin signs in (Clerk) and selects an Organization context +2. Admin opens Dashboard → Configurations +3. Admin sets: + - Model/provider (and API key presence) + - System prompt / instructions + - Knowledge base documents + - Escalation options (optional) +4. Admin opens Dashboard → Webchat +5. Admin customizes: + - Bot profile (name/avatar/description) + - Appearance (colors, theme mode, font, radius) + - Feature toggles +6. Admin copies the embed script snippet (Deploy settings) and installs it on a website + +Mermaid (high-level): + +```mermaid +sequenceDiagram + autonumber + participant Admin as Admin (Browser) + participant Web as Dashboard (apps/web) + participant Convex as Convex (packages/backend) + + Admin->>Web: Sign in + choose org + Admin->>Web: Configure model/prompt/KB + Web->>Convex: Save bot profile/config/documents + Admin->>Web: Configure appearance/features + Web->>Convex: Save appearance + feature flags + Admin->>Web: Copy embed snippet ``` -## Adding components +### Flow 2 — Visitor uses the widget on a customer website (no login) + +1. Customer website loads the embed script +2. Embed script: + - reads orgId/botId + - generates/persists visitorId + - creates host + Shadow DOM + - creates iframe to widget UI +3. Widget UI loads: + - fetches bot profile/config + - creates/validates a public session (publicSessions) +4. Visitor sends a message +5. System stores message and generates AI reply (optionally streaming) +6. Visitor sees responses in real time; messages are persisted for monitoring/analytics + +Mermaid (high-level): -To add components to your app, run the following command at the root of your `web` app: +```mermaid +sequenceDiagram + autonumber + participant Site as Customer Website + participant Embed as Embed Script (apps/widget/src/embed.ts) + participant Widget as Widget UI (iframe) + participant Convex as Convex backend -```bash -pnpm dlx shadcn@latest add button -c apps/web + Site->>Embed: Load `; - }, [botProfile]); + }, [embedToken]); // --- STATE MANAGEMENT --- const [chatInterface, setChatInterface] = useState<"toggle" | "embedded">( @@ -46,6 +61,21 @@ export default function DeploySettingsPage() { // Bisa tambah toast notification disini kalo mau }; + const handleGenerate = async () => { + if (!botProfile || !domain.trim()) return; + + setIsGenerating(true); + try { + const result = await generateEmbedToken({ + botId: botProfile._id, + domain: domain.trim(), + }); + setEmbedToken(result.token); + } finally { + setIsGenerating(false); + } + }; + return (
{/* --- HEADER --- */} @@ -78,6 +108,27 @@ export default function DeploySettingsPage() {

+
+
+ setDomain(e.target.value)} + placeholder="example.com or localhost:3000" + disabled={!botProfile || isGenerating} + /> +

+ Token will be scoped to this domain +

+
+ +
+
{botProfile === undefined && ( @@ -92,8 +143,7 @@ export default function DeploySettingsPage() { {botProfile && !embedScript && (
- Missing organization or bot ID. Make sure you are working - inside a Clerk organization. + Generate an embed token by entering a domain above.
)} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 35143b1..64fb404 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,14 +1,31 @@ -import Link from "next/link"; // Import Link -import { Button } from "@workspace/ui/components/button"; +"use client"; + +import { useEffect } from "react"; +import { useAuth } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; export default function Page() { + const { isLoaded, isSignedIn } = useAuth(); + const router = useRouter(); + + useEffect(() => { + // Wait for Clerk to load + if (!isLoaded) return; + + // If signed in, redirect to dashboard + if (isSignedIn) { + router.push("/dashboard/overview"); + } else { + // If not signed in, redirect to Clerk signin (Clerk hosted page) + router.push("/sign-in"); + } + }, [isLoaded, isSignedIn, router]); + + // Show loading state while checking auth return (
-
-

TODO: LANDING PAGE

- - - +
+

Loading...

); diff --git a/apps/web/components/configurations/bot-emulator.tsx b/apps/web/components/configurations/bot-emulator.tsx index 4230e0c..af5c7a7 100644 --- a/apps/web/components/configurations/bot-emulator.tsx +++ b/apps/web/components/configurations/bot-emulator.tsx @@ -95,7 +95,7 @@ export function BotEmulator() { try { setIsLoadingSession(true); - const session = await createOrGetSession({ botId: botProfile._id }); + const session = await createOrGetSession({ botId: botProfile._id! }); if (session) { const id = typeof session === "string" ? session : session._id; setSessionId(id); @@ -147,7 +147,7 @@ export function BotEmulator() { try { // Add user message to database await addEmulatorMessage({ - botId: botProfile._id, + botId: botProfile._id!, role: "user", content: userContent, }); @@ -158,7 +158,7 @@ export function BotEmulator() { ); // Start streaming response - await startStream(botProfile._id, sessionId, userContent); + await startStream(botProfile._id!, sessionId, userContent); console.log("[BotEmulator] Stream completed, fullText:", fullText); } catch (error) { @@ -174,7 +174,7 @@ export function BotEmulator() { try { setIsLoadingSession(true); - const newSessionId = await restartSession({ botId: botProfile._id }); + const newSessionId = await restartSession({ botId: botProfile._id! }); setSessionId(newSessionId); setMessages([]); } catch (error) { diff --git a/apps/web/components/configurations/bot-sidebar.tsx b/apps/web/components/configurations/bot-sidebar.tsx index 807a549..4d69582 100644 --- a/apps/web/components/configurations/bot-sidebar.tsx +++ b/apps/web/components/configurations/bot-sidebar.tsx @@ -24,11 +24,11 @@ interface BotSidebarProps { escalationConfig?: EscalationConfig; onEscalationConfigChange?: (next: EscalationConfig) => void; modelId?: string; - apiKey?: string; + hasApiKey?: boolean; onModelConfigChange?: (next: { modelId: string; modelProvider: string; - apiKey: string; + hasApiKey: boolean; }) => void; onModelConfigDeleted?: () => void; } @@ -42,7 +42,7 @@ export function BotSidebar({ escalationConfig, onEscalationConfigChange, modelId, - apiKey, + hasApiKey, onModelConfigChange, onModelConfigDeleted, }: BotSidebarProps) { @@ -111,7 +111,7 @@ export function BotSidebar({ escalationConfig={escalationConfig} onEscalationConfigChange={onEscalationConfigChange} modelId={modelId} - apiKey={apiKey} + hasApiKey={hasApiKey} onModelConfigChange={onModelConfigChange} onModelConfigDeleted={onModelConfigDeleted} /> diff --git a/apps/web/components/configurations/dynamic-inspector.tsx b/apps/web/components/configurations/dynamic-inspector.tsx index 8569734..d2ed380 100644 --- a/apps/web/components/configurations/dynamic-inspector.tsx +++ b/apps/web/components/configurations/dynamic-inspector.tsx @@ -55,11 +55,11 @@ interface DynamicInspectorProps { escalationConfig?: EscalationConfig; onEscalationConfigChange?: (next: EscalationConfig) => void; modelId?: string; - apiKey?: string; + hasApiKey?: boolean; onModelConfigChange?: (next: { modelId: string; modelProvider: string; - apiKey: string; + hasApiKey: boolean; }) => void; onModelConfigDeleted?: () => void; } @@ -108,7 +108,7 @@ export function DynamicInspector({ escalationConfig, onEscalationConfigChange, modelId, - apiKey, + hasApiKey, onModelConfigChange, onModelConfigDeleted, }: DynamicInspectorProps) { @@ -204,11 +204,12 @@ export function DynamicInspector({ : ("gemini-2.5-flash" as ModelId); setTempModelId(incomingModelId); - setTempApiKey(apiKey || ""); + // Never hydrate a stored API key back into the client. + setTempApiKey(""); setShowApiKey(false); setError(null); setSuccessMessage(null); - }, [mode, modelId, apiKey]); + }, [mode, modelId]); // ===== KNOWLEDGE BASE: SAVE HANDLER ===== const handleSaveKnowledgeBase = async () => { @@ -359,7 +360,9 @@ export function DynamicInspector({ setError("Invalid model selected"); return; } - if (!tempApiKey.trim()) { + const nextApiKey = tempApiKey.trim(); + const canSaveWithoutKey = Boolean(hasApiKey) && !nextApiKey; + if (!nextApiKey && !canSaveWithoutKey) { setError("API key is required"); return; } @@ -369,15 +372,19 @@ export function DynamicInspector({ await updateBotConfig({ model_id: tempModelId, model_provider: meta.provider, - api_key: tempApiKey, + api_key: nextApiKey ? nextApiKey : undefined, }); onModelConfigChange?.({ modelId: tempModelId, modelProvider: meta.provider, - apiKey: tempApiKey, + hasApiKey: true, }); + // Short-term safeguard: never keep the key in client state after saving. + setTempApiKey(""); + setShowApiKey(false); + setSuccessMessage("Model configuration saved successfully"); setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { @@ -570,6 +577,7 @@ export function DynamicInspector({ if (mode === "model") { const meta = MODEL_CONFIG[tempModelId]; const providerLabel = meta?.provider || "Provider"; + const hasStoredApiKey = Boolean(hasApiKey); return (
@@ -636,7 +644,11 @@ export function DynamicInspector({
setTempApiKey(e.target.value)} className="pr-10 font-mono text-xs bg-zinc-900/50 border-zinc-800" @@ -657,17 +669,22 @@ export function DynamicInspector({

- API keys are stored in plaintext on the backend for immediate - model access. + Keys are encrypted at rest and never shown again after saving.

+ ); + })} +
)}

- Never used documents + Retrieval coverage

- {stats.unusedDocumentIds.length === 0 ? ( -

- All documents were retrieved recently. -

- ) : ( -
    - {stats.unusedDocumentIds.slice(0, 5).map((id) => ( -
  • {docTitleMap.get(id) || "Untitled document"}
  • - ))} -
- )} + + {/* CONTAINER CHART: Dibuat Relative biar bisa tumpuk text di tengah */} +
+ + + + {donutData.map((entry, index) => ( + + ))} + + {/* Tooltip panggil component custom di atas */} + } cursor={false} /> + + + + {/* TEXT TENGAH: Ini kuncinya. Div Absolute di atas chart. */} +
+ + {retrievalCoveragePercent}% + +
+
+ +
+
+ + + Successful retrievals + + + {stats.successfulRetrievalQueries} + +
+
+ + + Fallback / no context + + + {stats.fallbackNoContextQueries} + +
+
diff --git a/apps/web/components/configurations/kb-document-list.tsx b/apps/web/components/configurations/kb-document-list.tsx index 433459b..2f0a9b1 100644 --- a/apps/web/components/configurations/kb-document-list.tsx +++ b/apps/web/components/configurations/kb-document-list.tsx @@ -43,9 +43,7 @@ export function KBDocumentList({ return documents.filter((doc) => { const title = extractTitleFromContent(doc.text).toLowerCase(); - return ( - title.includes(query) || doc.text.toLowerCase().includes(query) - ); + return title.includes(query) || doc.text.toLowerCase().includes(query); }); }, [documents, searchQuery]); @@ -75,7 +73,9 @@ export function KBDocumentList({
-

No documents found

+

+ No documents found +

{searchQuery && (

Try a different search @@ -85,7 +85,7 @@ export function KBDocumentList({ ) : ( -

+
{filteredDocs.map((doc) => { const stats = calculateDocStats(doc.text); const isSelected = selectedId diff --git a/apps/web/components/configurations/website-scraper-handler.tsx b/apps/web/components/configurations/website-scraper-handler.tsx index 6d46aa2..b735815 100644 --- a/apps/web/components/configurations/website-scraper-handler.tsx +++ b/apps/web/components/configurations/website-scraper-handler.tsx @@ -1,11 +1,79 @@ "use client"; import { useMemo, useState } from "react"; -import { Globe } from "lucide-react"; +import { Globe, Loader2, CheckCircle2, Check } from "lucide-react"; import { Button } from "@workspace/ui/components/button"; import { Input } from "@workspace/ui/components/input"; +import { ScrollArea } from "@workspace/ui/components/scroll-area"; import type { Id } from "@workspace/backend/convex/_generated/dataModel"; -import { useScrapeWebsiteAndAddKnowledge } from "@/lib/convex-client"; +import { + useCrawlWebsiteMeta, + useScrapeMultipleWebsitesAndAddKnowledge, +} from "@/lib/convex-client"; + +interface PageMetadata { + url: string; + title?: string; + description?: string; + estimated_size?: number; +} + +function isLikelyContentPage(url: string): boolean { + try { + const parsed = new URL(url); + const pathname = parsed.pathname.toLowerCase(); + const search = parsed.search.toLowerCase(); + + // Return false for homepage + if (pathname === "/" || pathname === "") { + return false; + } + + // Junk/list pages + const junkFragments = [ + "/tag/", + "/category/", + "/search/", + "/label/", + "/archive/", + "/page/", + ]; + if (junkFragments.some((frag) => pathname.includes(frag))) { + return false; + } + + // Junk query params (sorting/filtering/list views) + if ( + parsed.searchParams.has("sort") || + parsed.searchParams.has("filter") || + search.includes("sort=") || + search.includes("filter=") + ) { + return false; + } + + // Strong content signals + const contentFragments = ["/product/", "/item/", "/blog/", "/article/"]; + if (contentFragments.some((frag) => pathname.includes(frag))) { + return true; + } + + // Year patterns often indicate posts/articles + if (/(\/2023\/|\/2024\/|\/2025\/)/.test(pathname)) { + return true; + } + + // Path depth heuristic: domain.com/blog/my-post (depth 2) + const segments = pathname.split("/").filter(Boolean); + if (segments.length > 1) { + return true; + } + + return false; + } catch { + return false; + } +} function ProgressBar({ value }: { value: number }) { return ( @@ -18,6 +86,8 @@ function ProgressBar({ value }: { value: number }) { ); } +type Step = "input" | "discovery" | "selection" | "processing" | "complete"; + export function WebsiteScraperHandler({ botId, onCancel, @@ -27,11 +97,22 @@ export function WebsiteScraperHandler({ onCancel?: () => void; onComplete?: () => void; }) { - const scrapeWebsiteAndAddKnowledge = useScrapeWebsiteAndAddKnowledge(); + const crawlWebsiteMeta = useCrawlWebsiteMeta(); + const scrapeMultipleWebsitesAndAddKnowledge = + useScrapeMultipleWebsitesAndAddKnowledge(); + + const [step, setStep] = useState("input"); const [websiteUrl, setWebsiteUrl] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const [maxPages, setMaxPages] = useState(100); + const [maxDepth, setMaxDepth] = useState(3); + const [discoveredPages, setDiscoveredPages] = useState([]); + const [selectedUrls, setSelectedUrls] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(false); const [progress, setProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [processingErrors, setProcessingErrors] = useState< + Array<{ url: string; error: string }> + >([]); const urlError = useMemo(() => { if (!websiteUrl.trim()) return null; @@ -46,7 +127,7 @@ export function WebsiteScraperHandler({ } }, [websiteUrl]); - const handleScrape = async () => { + const handleDiscoverPages = async () => { if (!botId) { setErrorMessage("Bot profile is not ready yet. Please try again."); return; @@ -64,108 +145,466 @@ export function WebsiteScraperHandler({ } setErrorMessage(null); - setIsSubmitting(true); - setProgress(25); + setIsLoading(true); + setProgress(10); + setStep("discovery"); try { - setProgress(60); - await scrapeWebsiteAndAddKnowledge({ - botId, + setProgress(30); + const result = await crawlWebsiteMeta({ url, + maxPages, + maxDepth, }); + setProgress(70); + + if (result.pages.length === 0) { + throw new Error( + "No pages found in website or discovery failed. Check if the URL is correct.", + ); + } + + setDiscoveredPages(result.pages); + const allUrls = result.pages.map((p) => p.url); + const smartUrls = allUrls.filter((pageUrl) => + isLikelyContentPage(pageUrl), + ); + const initialSelection = smartUrls.length > 0 ? smartUrls : allUrls; + setSelectedUrls(new Set(initialSelection)); setProgress(100); - setWebsiteUrl(""); - onComplete?.(); + setStep("selection"); } catch (error) { setProgress(0); setErrorMessage( - error instanceof Error ? error.message : "Failed to scrape website", + error instanceof Error ? error.message : "Failed to discover pages", ); + setStep("input"); } finally { - setIsSubmitting(false); + setIsLoading(false); } }; - return ( -
-
-
-
- -

- We will crawl this URL and add the most relevant content. -

-
- - { - setWebsiteUrl(event.target.value); - if (errorMessage) setErrorMessage(null); - }} - /> + const handleTogglePage = (url: string, selected: boolean) => { + const newSelected = new Set(selectedUrls); + if (selected) { + newSelected.add(url); + } else { + newSelected.delete(url); + } + setSelectedUrls(newSelected); + }; + + const handleSelectAll = () => { + setSelectedUrls(new Set(discoveredPages.map((p) => p.url))); + }; + + const handleDeselectAll = () => { + setSelectedUrls(new Set()); + }; + + const handleConfirmAndAdd = async () => { + if (!botId) { + setErrorMessage("Bot profile is not ready yet. Please try again."); + return; + } + + if (selectedUrls.size === 0) { + setErrorMessage("Please select at least one page to add."); + return; + } + + setErrorMessage(null); + setProcessingErrors([]); + setIsLoading(true); + setProgress(10); + setStep("processing"); + + try { + setProgress(40); + const result = await scrapeMultipleWebsitesAndAddKnowledge({ + botId, + urls: Array.from(selectedUrls), + }); + + setProgress(80); + + if (!result.success && result.errors.length > 0) { + setProcessingErrors(result.errors); + } + + setProgress(100); + setStep("complete"); + setTimeout(() => { + onComplete?.(); + }, 2000); + } catch (error) { + setProgress(0); + setErrorMessage( + error instanceof Error ? error.message : "Failed to process pages", + ); + setStep("selection"); + } finally { + setIsLoading(false); + } + }; + + const resetForm = () => { + setStep("input"); + setWebsiteUrl(""); + setMaxPages(100); + setMaxDepth(3); + setDiscoveredPages([]); + setSelectedUrls(new Set()); + setErrorMessage(null); + setProcessingErrors([]); + setProgress(0); + }; + + // ==================== RENDER STEP: INPUT ==================== + if (step === "input") { + return ( +
+
+
+
+ +

+ Enter your website URL. We'll discover all pages and let + you choose which ones to add to the knowledge base. +

+
+ + { + setWebsiteUrl(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !isLoading && + websiteUrl.trim() && + !urlError + ) { + handleDiscoverPages(); + } + }} + disabled={isLoading} + /> +
+ {urlError && ( +

{urlError}

+ )} +
+ +
+ +

+ Customize how many pages and how deep to crawl. Higher values = + slower but more complete results. +

+
+
+ + + setMaxPages( + Math.min( + 1000, + Math.max(10, parseInt(e.target.value) || 100), + ), + ) + } + disabled={isLoading} + className="bg-zinc-900 border-zinc-700 focus:ring-zinc-600 text-zinc-100" + /> +

10 - 1000 pages

+
+
+ + + setMaxDepth( + Math.min( + 10, + Math.max(1, parseInt(e.target.value) || 3), + ), + ) + } + disabled={isLoading} + className="bg-zinc-900 border-zinc-700 focus:ring-zinc-600 text-zinc-100" + /> +

1 - 10 levels

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- {urlError && ( -

{urlError}

- )}
-
-
-
-
-
-
+ + {errorMessage && ( +
+

{errorMessage}

+
+ )} + +
+ + +
+
+ ); + } + + // ==================== RENDER STEP: DISCOVERY/SELECTION ==================== + if (step === "selection") { + return ( +
+
+
+

+ Found {discoveredPages.length} Pages +

+

+ Select which pages to add to your knowledge base +

+
+ +
+ + +
+ {selectedUrls.size} of {discoveredPages.length} selected
-
-
-
-
-
-
+
+ + +
+ {discoveredPages.map((page) => ( +
+ handleTogglePage(page.url, !selectedUrls.has(page.url)) + } + > +
+
+ {selectedUrls.has(page.url) && ( + + )} +
+
+
+

+ {page.title || "Untitled Page"} +

+

+ {page.url} +

+ {page.description && ( +

+ {page.description} +

+ )} +
+
+ ))}
+
+
+ + {errorMessage && ( +
+

{errorMessage}

+ )} + +
+ +
+ ); + } - {isSubmitting && ( -
+ // ==================== RENDER STEP: PROCESSING ==================== + if (step === "processing") { + return ( +
+
+ +

+ Scraping and Processing Pages +

+

+ Extracting content and generating embeddings... +

+
+ +
-

- Scraping content and generating embeddings... +

+ {Math.round(progress)}% complete

- )} - - {errorMessage && ( -

{errorMessage}

- )} - -
- - + + {processingErrors.length > 0 && ( +
+

+ Some pages failed to process: +

+
+ {processingErrors.slice(0, 3).map((err) => ( +

+ • {new URL(err.url).pathname || err.url}: {err.error} +

+ ))} +
+
+ )}
-
- ); + ); + } + + // ==================== RENDER STEP: COMPLETE ==================== + if (step === "complete") { + return ( +
+
+ +

+ Pages Added Successfully! +

+

+ {selectedUrls.size} page{selectedUrls.size === 1 ? "" : "s"} have + been added to your knowledge base. +

+
+ + {processingErrors.length > 0 && ( +
+

+ {processingErrors.length} page + {processingErrors.length === 1 ? "" : "s"} failed: +

+
+ {processingErrors.map((err) => ( +

+ • {new URL(err.url).pathname}: {err.error} +

+ ))} +
+
+ )} + +

Redirecting...

+
+ ); + } } diff --git a/apps/web/components/providers.tsx b/apps/web/components/providers.tsx index 8d20c49..495f761 100644 --- a/apps/web/components/providers.tsx +++ b/apps/web/components/providers.tsx @@ -15,8 +15,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( and components afterSignOutUrl="/" > diff --git a/apps/web/components/webchat/bot-widget.tsx b/apps/web/components/webchat/bot-widget.tsx index b4ed0e5..abc0f07 100644 --- a/apps/web/components/webchat/bot-widget.tsx +++ b/apps/web/components/webchat/bot-widget.tsx @@ -72,7 +72,7 @@ export function BotWidget({ className }: BotWidgetProps) { try { setIsLoadingSession(true); - const session = await createOrGetSession({ botId: botProfile._id }); + const session = await createOrGetSession({ botId: botProfile._id! }); if (session) { const id = typeof session === "string" ? session : session._id; setSessionId(id); @@ -146,7 +146,7 @@ export function BotWidget({ className }: BotWidgetProps) { try { // Add user message to database await addPlaygroundMessage({ - botId: botProfile._id, + botId: botProfile._id!, role: "user", content: userContent, }); @@ -161,7 +161,7 @@ export function BotWidget({ className }: BotWidgetProps) { try { // Start streaming - this will populate chunks in real-time - await startStream(botProfile._id, sessionId, userContent); + await startStream(botProfile._id!, sessionId, userContent); // Stream completed successfully - response is now in database console.log("[handleSend] Streaming completed successfully"); @@ -189,7 +189,7 @@ export function BotWidget({ className }: BotWidgetProps) { // Generate AI response using the unified AI engine await generateBotResponse({ - botId: botProfile._id, + botId: botProfile._id!, // eslint-disable-next-line @typescript-eslint/no-explicit-any conversationId: sessionId as any, userMessage: userContent, @@ -216,7 +216,7 @@ export function BotWidget({ className }: BotWidgetProps) { try { setIsLoadingSession(true); - const newSessionId = await restartSession({ botId: botProfile._id }); + const newSessionId = await restartSession({ botId: botProfile._id! }); setSessionId(newSessionId); setDbMessages([]); // Clear DB messages when restarting session setStreamingMessageId(null); // Clear any streaming state @@ -277,10 +277,10 @@ export function BotWidget({ className }: BotWidgetProps) { // Create mock session for shared component const session: ChatSession = { - id: sessionId || "preview-session", + sessionToken: "preview-session-token", + conversationId: sessionId || "preview-conversation", organizationId: "web-app", botId: botProfile?._id || "preview", - visitorId: "admin-test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; diff --git a/apps/web/contexts/webchat-context.tsx b/apps/web/contexts/webchat-context.tsx index 291ed45..89a36c7 100644 --- a/apps/web/contexts/webchat-context.tsx +++ b/apps/web/contexts/webchat-context.tsx @@ -143,26 +143,32 @@ export function WebchatProvider({ children }: { children: ReactNode }) { // Update local state when botProfile is loaded from Convex useEffect(() => { if (botProfile) { - setDisplayName(botProfile.bot_names); - setDescription(botProfile.bot_description); - setPlaceholder(botProfile.msg_placeholder); - setPrimaryColor(botProfile.primary_color); - setAvatarUrl(botProfile.avatar_url); - setFont(botProfile.font); - setThemeMode(botProfile.theme_mode as "light" | "dark"); - setHeaderStyle(botProfile.header_style as "basic" | "branded"); - setMessageStyle(botProfile.message_style as "filled" | "outlined"); - setCornerRadius(botProfile.corner_radius); - setEnableFeedback(botProfile.enable_feedback); - setEnableFileUpload(botProfile.enable_file_upload); - setEnableSound(botProfile.enable_sound); - setHistoryReset(botProfile.history_reset); + setDisplayName(botProfile.bot_names ?? "ChatBot"); + setDescription(botProfile.bot_description ?? ""); + setPlaceholder(botProfile.msg_placeholder ?? "Type a message..."); + setPrimaryColor(botProfile.primary_color ?? "#000000"); + setAvatarUrl(botProfile.avatar_url ?? ""); + setFont(botProfile.font ?? "sans-serif"); + setThemeMode((botProfile.theme_mode as "light" | "dark") ?? "light"); + setHeaderStyle( + (botProfile.header_style as "basic" | "branded") ?? "basic", + ); + setMessageStyle( + (botProfile.message_style as "filled" | "outlined") ?? "filled", + ); + setCornerRadius(botProfile.corner_radius ?? 8); + setEnableFeedback(botProfile.enable_feedback ?? true); + setEnableFileUpload(botProfile.enable_file_upload ?? false); + setEnableSound(botProfile.enable_sound ?? true); + setHistoryReset(botProfile.history_reset ?? "on_reload"); } }, [botProfile]); // Save profile to Convex const saveProfile = async () => { - if (!botProfile) throw new Error("Profile not loaded"); + if (!botProfile || !botProfile._id) { + throw new Error("Profile not loaded"); + } try { await updateBotProfile({ id: botProfile._id, diff --git a/apps/web/lib/convex-client.ts b/apps/web/lib/convex-client.ts index 1c15970..0cd0b13 100644 --- a/apps/web/lib/convex-client.ts +++ b/apps/web/lib/convex-client.ts @@ -49,6 +49,12 @@ export function useUpdateBotProfile() { return useMutation(api.webchat.updateBotProfile); } +// ===== EMBED TOKENS ===== + +export function useGenerateEmbedToken() { + return useMutation(api.embedTokens.generateEmbedToken); +} + // ===== CONFIGURATION HOOKS ===== /** @@ -100,7 +106,7 @@ export function useAdminConversations(botId?: Id<"botProfiles"> | "skip") { } /** - * Hook to fetch public visitor conversations (conversations with visitor_id, no user_id) + * Hook to fetch public visitor conversations (conversations with visitor_id) * ✅ Used for "Visitor Chats" tab in admin dashboard * Pass botId to fetch, or pass "skip" to skip the query */ @@ -477,6 +483,19 @@ export function useKnowledgeUtilization( ); } +/** + * Hook to fetch AI performance time-series (calls + tokens) for Overview chart. + */ +export function useAIPerformanceSeries( + botId?: Id<"botProfiles"> | "skip", + days: number = 1, +) { + return useQuery( + api.aiAnalytics.getAIPerformanceSeries, + botId && botId !== "skip" ? { botId, days } : "skip", + ); +} + /** * Hook to fetch knowledge base usage stats */ @@ -515,6 +534,20 @@ export function useScrapeWebsiteAndAddKnowledge() { return useAction(api.knowledge.scrapeWebsiteAndAddKnowledge); } +/** + * Hook to crawl a website and get list of discoverable pages + */ +export function useCrawlWebsiteMeta() { + return useAction(api.knowledge.crawlWebsiteMeta); +} + +/** + * Hook to scrape multiple website URLs and add as knowledge + */ +export function useScrapeMultipleWebsitesAndAddKnowledge() { + return useAction(api.knowledge.scrapeMultipleWebsitesAndAddKnowledge); +} + /** * Hook to add knowledge with source metadata */ diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index c8d7064..92a4ed6 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,14 +1,12 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; // Public routes that don't require authentication +// Clerk handles signin/signup with hosted pages - removed from here const isPublicRoute = createRouteMatcher([ - "/signin(.*)", - "/signup(.*)", "/api(.*)", // API routes handled separately - "/forgot-password(.*)", - "/reset-password(.*)", - "/onboarding(.*)", // Allow onboarding without organization check - "/", // Home page + "/", // Home page - will redirect based on auth status + "/sso-callback(.*)", // SSO callback route + "/widget-demo(.*)", // Widget demo (public) ]); export default clerkMiddleware(async (auth, req) => { diff --git a/apps/widget/app/widget/page.tsx b/apps/widget/app/widget/page.tsx index 82311d8..b945558 100644 --- a/apps/widget/app/widget/page.tsx +++ b/apps/widget/app/widget/page.tsx @@ -20,9 +20,11 @@ import { function WidgetContent() { const searchParams = useSearchParams(); - const orgId = searchParams.get("orgId"); - const botId = searchParams.get("botId"); - const visitorId = searchParams.get("visitorId"); + const token = searchParams.get("token"); + + const [currentDomain, setCurrentDomain] = useState( + undefined, + ); const [session, setSession] = useState(null); const [messages, setMessages] = useState([]); @@ -32,10 +34,23 @@ function WidgetContent() { const [hydratedTheme, setHydratedTheme] = useState(null); const [isOnline, setIsOnline] = useState(false); + useEffect(() => { + try { + if (!document.referrer) { + setCurrentDomain(undefined); + return; + } + const hostname = new URL(document.referrer).hostname; + setCurrentDomain(hostname || undefined); + } catch { + setCurrentDomain(undefined); + } + }, []); + // Fetch bot config const config = useQuery( - api.public.getBotProfile, - orgId && botId ? { organizationId: orgId, botId } : "skip", + api.public.validateEmbedToken, + token ? { token, currentDomain } : "skip", ); // Create session @@ -58,17 +73,15 @@ function WidgetContent() { api.public.getMessages, session ? { - sessionId: session.id, - organizationId: orgId || "", - botId: botId || "", - visitorId: session.visitorId, + conversationId: session.conversationId, + sessionToken: session.sessionToken, } : "skip", ); // Validate parameters and load bot config useEffect(() => { - if (!orgId || !botId || !visitorId) { + if (!token) { setError(new Error("Missing required parameters")); notifyError("Missing required parameters"); setIsOnline(false); @@ -91,7 +104,7 @@ function WidgetContent() { botConfigData.appearance.cornerRadius, ); } - }, [orgId, botId, visitorId, config, hydratedTheme]); + }, [token, config, hydratedTheme]); // Sync messages from subscription useEffect(() => { @@ -102,7 +115,7 @@ function WidgetContent() { const handleSendMessage = useCallback( async (content: string) => { - if (!orgId || !botId || !visitorId) { + if (!token) { setError(new Error("Missing required parameters")); return; } @@ -114,22 +127,21 @@ function WidgetContent() { let currentSession = session; if (!currentSession) { const newSession = await createSessionMutation({ - organizationId: orgId, - botId, - visitorId, + token, + currentDomain, }); const sessionData: ChatSession = { - id: newSession.sessionId, - organizationId: orgId, - botId, - visitorId, + sessionToken: newSession.sessionToken, + conversationId: newSession.conversationId, + organizationId: botConfig?.organizationId ?? "", + botId: botConfig?.id ?? "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; setSession(sessionData); notifyReady( - newSession.sessionId, + newSession.conversationId, botConfig?.appearance.primaryColor, botConfig?.appearance.cornerRadius, ); @@ -138,20 +150,16 @@ function WidgetContent() { // Send message using the session await sendMessageMutation({ - sessionId: currentSession.id, - organizationId: currentSession.organizationId, - botId: currentSession.botId, - visitorId: currentSession.visitorId, + conversationId: currentSession.conversationId, + sessionToken: currentSession.sessionToken, content, }); // Trigger AI response generation (streamed via DB updates) console.log("[Widget] Generating AI response (streaming)..."); await generateReplyAction({ - sessionId: currentSession.id, - organizationId: currentSession.organizationId, - botId: currentSession.botId, - visitorId: currentSession.visitorId, + conversationId: currentSession.conversationId, + sessionToken: currentSession.sessionToken, userMessage: content, }); @@ -173,9 +181,8 @@ function WidgetContent() { } }, [ - orgId, - botId, - visitorId, + token, + currentDomain, session, botConfig, createSessionMutation, @@ -189,13 +196,11 @@ function WidgetContent() { * A new session will be lazily created on the next message send */ const handleRefresh = useCallback(async () => { - if (session && orgId && botId && visitorId) { + if (session) { try { await endSessionMutation({ - sessionId: session.id, - organizationId: orgId, - botId, - visitorId, + conversationId: session.conversationId, + sessionToken: session.sessionToken, }); } catch (err) { console.warn("[Widget] Failed to end session:", err); @@ -208,11 +213,11 @@ function WidgetContent() { setMessages([]); setError(null); setIsStreaming(false); - }, [session, orgId, botId, visitorId, endSessionMutation]); + }, [session, endSessionMutation]); const handleLeadClick = useCallback( async (payload: LeadClickPayload) => { - if (!session || !orgId || !botId || !visitorId) return; + if (!session) return; const eventType = payload.type === "whatsapp" @@ -221,10 +226,8 @@ function WidgetContent() { try { await trackEventMutation({ - sessionId: session.id, - organizationId: orgId, - botId, - visitorId, + conversationId: session.conversationId, + sessionToken: session.sessionToken, eventType, href: payload.href, }); @@ -232,7 +235,7 @@ function WidgetContent() { console.warn("[Widget] Failed to track lead event:", err); } }, - [session, orgId, botId, visitorId, trackEventMutation], + [session, trackEventMutation], ); // Error state @@ -280,7 +283,7 @@ function WidgetContent() { style={{ height: "100%", width: "100%" }} > {(function(){let c=document.currentScript;if(!c){console.error("Chatify: Unable to determine script element");return}let m=c.getAttribute("data-organization-id"),f=c.getAttribute("data-bot-id"),u=c.getAttribute("data-position")||"bottom-right";if(!m||!f){console.error("Chatify: Missing data-organization-id or data-bot-id attributes");return}let b="http://localhost:3001",y="chatify_visitor_id",h="chatify_visitor_id_createdAt",I="#6366f1";function T(t){let e=new Date(t);return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()+1,0,0,0,0)}function _(t){if(!t.startsWith("visitor_"))return null;let e=t.split("_");if(e.length<3)return null;let a=Number(e[1]);return Number.isFinite(a)?a:null}function E(t,e){if(!Number.isFinite(t)||t>e)return!0;let a=T(t);return e>=a}function P(t){return`visitor_${t}_${Math.random().toString(36).substr(2,9)}`}function A(){let t=Date.now(),e=localStorage.getItem(y),a=localStorage.getItem(h),o=a?Number(a):null;if(!o&&e){let n=_(e);n&&Number.isFinite(n)&&(o=n,localStorage.setItem(h,String(o)))}if(!e||!o||E(o,t)){let n=P(t);return localStorage.setItem(y,n),localStorage.setItem(h,String(t)),n}return e}function R(t){switch(t){case"bottom-left":return{horizontal:"left",horizontalValue:"0",vertical:"bottom",verticalValue:"0"};case"top-right":return{horizontal:"right",horizontalValue:"0",vertical:"top",verticalValue:"0"};case"top-left":return{horizontal:"left",horizontalValue:"0",vertical:"top",verticalValue:"0"};case"bottom-right":default:return{horizontal:"right",horizontalValue:"0",vertical:"bottom",verticalValue:"0"}}}function L(t){switch(t){case"bottom-left":return{horizontal:"left",horizontalValue:"20px",vertical:"bottom",verticalValue:"20px"};case"top-right":return{horizontal:"right",horizontalValue:"20px",vertical:"top",verticalValue:"20px"};case"top-left":return{horizontal:"left",horizontalValue:"20px",vertical:"top",verticalValue:"20px"};case"bottom-right":default:return{horizontal:"right",horizontalValue:"20px",vertical:"bottom",verticalValue:"20px"}}}function S(t){return t.startsWith("top")?"top: 80px":"bottom: 80px"}function v(){return` +"use strict";(()=>{(function(){let m=document.currentScript;if(!m){console.error("Chatify: Unable to determine script element");return}let g=m.getAttribute("data-token"),d=m.getAttribute("data-position")||"bottom-right";if(!g){console.error("Chatify: Missing data-token attribute");return}let f=g,b="https://vim-widget.vercel.app/",y="chatify_visitor_id",u="chatify_visitor_id_createdAt",T="#6366f1";function _(t){let e=new Date(t);return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()+1,0,0,0,0)}function E(t){if(!t.startsWith("visitor_"))return null;let e=t.split("_");if(e.length<3)return null;let n=Number(e[1]);return Number.isFinite(n)?n:null}function P(t,e){if(!Number.isFinite(t)||t>e)return!0;let n=_(t);return e>=n}function I(t){return`visitor_${t}_${Math.random().toString(36).substr(2,9)}`}function R(){let t=Date.now(),e=localStorage.getItem(y),n=localStorage.getItem(u),o=n?Number(n):null;if(!o&&e){let a=E(e);a&&Number.isFinite(a)&&(o=a,localStorage.setItem(u,String(o)))}if(!e||!o||P(o,t)){let a=I(t);return localStorage.setItem(y,a),localStorage.setItem(u,String(t)),a}return e}function k(t){switch(t){case"bottom-left":return{horizontal:"left",horizontalValue:"0",vertical:"bottom",verticalValue:"0"};case"top-right":return{horizontal:"right",horizontalValue:"0",vertical:"top",verticalValue:"0"};case"top-left":return{horizontal:"left",horizontalValue:"0",vertical:"top",verticalValue:"0"};case"bottom-right":default:return{horizontal:"right",horizontalValue:"0",vertical:"bottom",verticalValue:"0"}}}function A(t){switch(t){case"bottom-left":return{horizontal:"left",horizontalValue:"20px",vertical:"bottom",verticalValue:"20px"};case"top-right":return{horizontal:"right",horizontalValue:"20px",vertical:"top",verticalValue:"20px"};case"top-left":return{horizontal:"left",horizontalValue:"20px",vertical:"top",verticalValue:"20px"};case"bottom-right":default:return{horizontal:"right",horizontalValue:"20px",vertical:"bottom",verticalValue:"20px"}}}function L(t){return t.startsWith("top")?"top: 80px":"bottom: 80px"}function v(){return` - `}function $(){return` + `}function S(){return` - `}function x(){let t=document.createElement("div");t.id=`chatify-widget-${m}`,t.setAttribute("data-chatify-widget","true");let e=R(u);t.style.setProperty("position","fixed","important"),t.style.setProperty(e.horizontal,e.horizontalValue,"important"),t.style.setProperty(e.vertical,e.verticalValue,"important"),t.style.setProperty("width","0","important"),t.style.setProperty("height","0","important"),t.style.setProperty("z-index","2147483647","important"),t.style.setProperty("pointer-events","none","important"),t.style.setProperty("margin","0","important"),t.style.setProperty("padding","0","important"),t.style.setProperty("border","0","important"),t.style.setProperty("background","transparent","important"),t.style.setProperty("box-shadow","none","important"),t.style.setProperty("outline","none","important"),t.style.setProperty("overflow","visible","important"),document.body.appendChild(t);let a=t.attachShadow({mode:"open"}),o=L(u),n=S(u),w=document.createElement("style");w.textContent=` + `}function x(){let t=document.createElement("div");t.id=`chatify-widget-${f}`,t.setAttribute("data-chatify-widget","true");let e=k(d);t.style.setProperty("position","fixed","important"),t.style.setProperty(e.horizontal,e.horizontalValue,"important"),t.style.setProperty(e.vertical,e.verticalValue,"important"),t.style.setProperty("width","0","important"),t.style.setProperty("height","0","important"),t.style.setProperty("z-index","2147483647","important"),t.style.setProperty("pointer-events","none","important"),t.style.setProperty("margin","0","important"),t.style.setProperty("padding","0","important"),t.style.setProperty("border","0","important"),t.style.setProperty("background","transparent","important"),t.style.setProperty("box-shadow","none","important"),t.style.setProperty("outline","none","important"),t.style.setProperty("overflow","visible","important"),document.body.appendChild(t);let n=t.attachShadow({mode:"open"}),o=A(d),a=L(d),w=document.createElement("style");w.textContent=` * { box-sizing: border-box; } @@ -63,7 +63,7 @@ #chatify-container { position: absolute !important; ${o.horizontal}: ${o.horizontalValue} !important; - ${n} !important; + ${a} !important; width: 380px !important; height: 640px !important; max-width: calc(100vw - 20px) !important; @@ -113,4 +113,4 @@ z-index: 9999999 !important; } } - `,a.appendChild(w);let i=document.createElement("button");i.id="chatify-button",i.innerHTML=v(),i.setAttribute("aria-label","Open chat widget"),i.setAttribute("aria-expanded","false"),a.appendChild(i);let l=document.createElement("div");l.id="chatify-container";let s=document.createElement("iframe");s.id="chatify-iframe",s.src=`${b}/widget?orgId=${m}&botId=${f}&visitorId=${A()}`,s.allow="camera; microphone",s.setAttribute("loading","lazy"),s.setAttribute("title","Chatify Chat Widget"),l.appendChild(s),a.appendChild(l);let g=!1,d=I;function z(p){if(typeof p!="number"||Number.isNaN(p))return;let r=`${Math.max(0,p)}px`;l.style.setProperty("border-radius",r,"important"),s.style.setProperty("border-radius",r,"important")}function O(){g=!0,l.classList.add("active"),i.classList.add("active"),i.innerHTML=$(),i.setAttribute("aria-expanded","true"),i.setAttribute("aria-label","Close chat widget")}function C(){g=!1,l.classList.remove("active"),i.classList.remove("active"),i.innerHTML=v(),i.setAttribute("aria-expanded","false"),i.setAttribute("aria-label","Open chat widget")}function k(){g?C():O()}i.addEventListener("click",k),window.addEventListener("message",p=>{let V=new URL(b).origin;if(p.origin!==V)return;let r=p.data;r.type==="widget:close"&&C(),r.type==="widget:ready"&&r.data&&(r.data.primaryColor&&(d=r.data.primaryColor,i.style.setProperty("background",d,"important")),r.data.cornerRadius!=null&&z(r.data.cornerRadius)),r.type==="widget:config"&&r.data&&(r.data.primaryColor&&(d=r.data.primaryColor,i.style.setProperty("background",d,"important")),r.data.cornerRadius!=null&&z(r.data.cornerRadius))}),console.log("Chatify widget loaded successfully")}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",x):x()})();})(); + `,n.appendChild(w);let i=document.createElement("button");i.id="chatify-button",i.innerHTML=v(),i.setAttribute("aria-label","Open chat widget"),i.setAttribute("aria-expanded","false"),n.appendChild(i);let l=document.createElement("div");l.id="chatify-container";let s=document.createElement("iframe");s.id="chatify-iframe",s.src=`${b}/widget?token=${encodeURIComponent(f)}&visitorId=${encodeURIComponent(R())}`,s.allow="camera; microphone",s.setAttribute("loading","lazy"),s.setAttribute("title","Chatify Chat Widget"),l.appendChild(s),n.appendChild(l);let h=!1,c=T;function C(p){if(typeof p!="number"||Number.isNaN(p))return;let r=`${Math.max(0,p)}px`;l.style.setProperty("border-radius",r,"important"),s.style.setProperty("border-radius",r,"important")}function O(){h=!0,l.classList.add("active"),i.classList.add("active"),i.innerHTML=S(),i.setAttribute("aria-expanded","true"),i.setAttribute("aria-label","Close chat widget")}function V(){h=!1,l.classList.remove("active"),i.classList.remove("active"),i.innerHTML=v(),i.setAttribute("aria-expanded","false"),i.setAttribute("aria-label","Open chat widget")}function $(){h?V():O()}i.addEventListener("click",$),window.addEventListener("message",p=>{let z=new URL(b).origin;if(p.origin!==z)return;let r=p.data;r.type==="widget:close"&&V(),r.type==="widget:ready"&&r.data&&(r.data.primaryColor&&(c=r.data.primaryColor,i.style.setProperty("background",c,"important")),r.data.cornerRadius!=null&&C(r.data.cornerRadius)),r.type==="widget:config"&&r.data&&(r.data.primaryColor&&(c=r.data.primaryColor,i.style.setProperty("background",c,"important")),r.data.cornerRadius!=null&&C(r.data.cornerRadius))}),console.log("Chatify widget loaded successfully")}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",x):x()})();})(); diff --git a/apps/widget/src/embed.ts b/apps/widget/src/embed.ts index 8e0610e..d95d891 100644 --- a/apps/widget/src/embed.ts +++ b/apps/widget/src/embed.ts @@ -5,8 +5,7 @@ * * */ @@ -18,8 +17,7 @@ declare global { } interface EmbedConfig { - organizationId: string; - botId: string; + token: string; position: "bottom-right" | "bottom-left" | "top-right" | "top-left"; theme: "light" | "dark"; } @@ -37,18 +35,18 @@ interface BotProfileResponse { return; } - const organizationId = scriptTag.getAttribute("data-organization-id"); - const botId = scriptTag.getAttribute("data-bot-id"); + const token = scriptTag.getAttribute("data-token"); const position = (scriptTag.getAttribute("data-position") || "bottom-right") as EmbedConfig["position"]; - if (!organizationId || !botId) { - console.error( - "Chatify: Missing data-organization-id or data-bot-id attributes", - ); + if (!token) { + console.error("Chatify: Missing data-token attribute"); return; } + // Capture a non-null token for nested closures. + const embedToken: string = token; + // embed.ts // Use injected/env variable for widget URL, fallback to production const WIDGET_URL = @@ -234,7 +232,7 @@ interface BotProfileResponse { // 1. CREATE HOST (0x0 fixed element - zero layout impact) // ═══════════════════════════════════════════════════════════════════ const host = document.createElement("div"); - host.id = `chatify-widget-${organizationId}`; + host.id = `chatify-widget-${embedToken}`; host.setAttribute("data-chatify-widget", "true"); const hostPos = getHostPosition(position); @@ -407,7 +405,7 @@ interface BotProfileResponse { // ═══════════════════════════════════════════════════════════════════ const iframe = document.createElement("iframe"); iframe.id = "chatify-iframe"; - iframe.src = `${WIDGET_URL}/widget?orgId=${organizationId}&botId=${botId}&visitorId=${getVisitorId()}`; + iframe.src = `${WIDGET_URL}/widget?token=${encodeURIComponent(embedToken)}&visitorId=${encodeURIComponent(getVisitorId())}`; iframe.allow = "camera; microphone"; iframe.setAttribute("loading", "lazy"); iframe.setAttribute("title", "Chatify Chat Widget"); diff --git a/apps/widget/tsup.config.ts b/apps/widget/tsup.config.ts index 82ab72c..b7a6241 100644 --- a/apps/widget/tsup.config.ts +++ b/apps/widget/tsup.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ minify: true, clean: true, define: { - "globalThis.__WIDGET_URL__": JSON.stringify("http://localhost:3001"), + "globalThis.__WIDGET_URL__": JSON.stringify( + "https://vim-widget.vercel.app/", + ), }, }); diff --git a/packages/backend/__tests__/helpers/fixtures.ts b/packages/backend/__tests__/helpers/fixtures.ts index f36ee5a..97f9468 100644 --- a/packages/backend/__tests__/helpers/fixtures.ts +++ b/packages/backend/__tests__/helpers/fixtures.ts @@ -1,6 +1,6 @@ import { ConvexHttpClient } from "convex/browser"; -import { anyApi } from "convex/server"; import type { Id } from "../../convex/_generated/dataModel.js"; +import { api } from "../../convex/_generated/api.js"; type EscalationConfig = { enabled: boolean; @@ -119,7 +119,7 @@ export async function resetTestData( organizationId = DEFAULT_ORGANIZATION_ID, client: ConvexHttpClient = getTestClient(), ) { - return await client.mutation(anyApi.testing.resetTestData, { + return await client.mutation(api.testing.resetTestData, { organizationId, }); } @@ -142,7 +142,7 @@ export async function seedBotProfile( }); return (await client.mutation( - anyApi.testing.insertBotProfile, + api.testing.insertBotProfile, args, )) as Id<"botProfiles">; } @@ -166,7 +166,7 @@ export async function seedConversation( }); return (await client.mutation( - anyApi.testing.insertConversation, + api.testing.insertConversation, args, )) as Id<"conversations">; } @@ -186,7 +186,7 @@ export async function seedMessage( }); return (await client.mutation( - anyApi.testing.insertMessage, + api.testing.insertMessage, args, )) as Id<"messages">; } @@ -205,7 +205,7 @@ export async function seedKnowledge( }); return (await client.mutation( - anyApi.testing.insertDocument, + api.testing.insertDocument, args, )) as Id<"documents">; } @@ -225,7 +225,7 @@ export async function seedPublicSession( }); return (await client.mutation( - anyApi.testing.insertPublicSession, + api.testing.insertPublicSession, args, )) as Id<"publicSessions">; } diff --git a/packages/backend/__tests__/integration/public-widget-session.test.ts b/packages/backend/__tests__/integration/public-widget-session.test.ts deleted file mode 100644 index 2cd54fc..0000000 --- a/packages/backend/__tests__/integration/public-widget-session.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { api } from "../../convex/_generated/api.js"; -import { - DEFAULT_ORGANIZATION_ID, - DEFAULT_USER_ID, - getTestClient, - resetTestClient, - resetTestData, - seedBotProfile, -} from "../helpers/fixtures"; - -describe("public widget session lifecycle", () => { - beforeAll(async () => { - await resetTestData(); - }); - - afterAll(() => { - resetTestClient(); - }); - - it("creates a session, generates a reply, and closes", async () => { - const client = getTestClient(); - - const botId = await seedBotProfile({ - userId: DEFAULT_USER_ID, - organizationId: DEFAULT_ORGANIZATION_ID, - modelProvider: "OpenAI", - modelId: "gpt-4o-mini", - apiKey: "test-api-key", - }); - - const session = await client.mutation(api.public.createSession, { - organizationId: DEFAULT_ORGANIZATION_ID, - botId: botId as any, - }); - - expect(session.sessionId).toBeTruthy(); - expect(session.conversationId).toBeTruthy(); - expect(session.visitorId).toBeTruthy(); - - const userMessage = "Hello"; - - await client.mutation(api.public.sendMessage, { - sessionId: session.sessionId, - organizationId: DEFAULT_ORGANIZATION_ID, - botId: botId as any, - visitorId: session.visitorId, - content: userMessage, - }); - - const reply = await client.action(api.public.generateReply, { - sessionId: session.sessionId, - organizationId: DEFAULT_ORGANIZATION_ID, - botId: botId as any, - visitorId: session.visitorId, - userMessage, - }); - - expect(reply.success).toBe(true); - expect(reply.content).toBeTruthy(); - - const messages = await client.query(api.public.getMessages, { - sessionId: session.sessionId, - organizationId: DEFAULT_ORGANIZATION_ID, - botId: botId as any, - visitorId: session.visitorId, - }); - - const roles = messages.map((message: { role: string }) => message.role); - expect(roles).toContain("user"); - expect(roles).toContain("bot"); - - const endResult = await client.mutation(api.public.endSession, { - sessionId: session.sessionId, - organizationId: DEFAULT_ORGANIZATION_ID, - botId: botId as any, - visitorId: session.visitorId, - }); - - expect(endResult.success).toBe(true); - - const publicSession = await client.query(api.testing.getPublicSession, { - sessionId: session.sessionId, - }); - - expect(publicSession?.status).toBe("ended"); - - const conversation = await client.query(api.testing.getConversation, { - conversationId: session.conversationId as any, - }); - - expect(conversation?.status).toBe("closed"); - - const aiLogs = await client.query(api.testing.getAiLogsForConversation, { - conversationId: session.conversationId as any, - }); - - expect(aiLogs.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 601dba7..01cda6e 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type * as aiAnalytics from "../aiAnalytics.js"; import type * as analytics from "../analytics.js"; import type * as configuration from "../configuration.js"; import type * as documentchunker from "../documentchunker.js"; +import type * as embedTokens from "../embedTokens.js"; import type * as functions_public_createSession from "../functions/public/createSession.js"; import type * as functions_public_endSession from "../functions/public/endSession.js"; import type * as functions_public_generateReply from "../functions/public/generateReply.js"; @@ -24,8 +25,10 @@ import type * as functions_public_getSessionDetails from "../functions/public/ge import type * as functions_public_index from "../functions/public/index.js"; import type * as functions_public_sendMessage from "../functions/public/sendMessage.js"; import type * as functions_public_trackEvent from "../functions/public/trackEvent.js"; +import type * as functions_public_validateEmbedToken from "../functions/public/validateEmbedToken.js"; import type * as kbanalytics from "../kbanalytics.js"; import type * as knowledge from "../knowledge.js"; +import type * as lib_security from "../lib/security.js"; import type * as migrations from "../migrations.js"; import type * as modelproviders from "../modelproviders.js"; import type * as monitor from "../monitor.js"; @@ -33,6 +36,7 @@ import type * as pdfparser from "../pdfparser.js"; import type * as playground from "../playground.js"; import type * as public_ from "../public.js"; import type * as rag from "../rag.js"; +import type * as secrets from "../secrets.js"; import type * as testing from "../testing.js"; import type * as webchat from "../webchat.js"; import type * as websitescraper from "../websitescraper.js"; @@ -49,6 +53,7 @@ declare const fullApi: ApiFromModules<{ analytics: typeof analytics; configuration: typeof configuration; documentchunker: typeof documentchunker; + embedTokens: typeof embedTokens; "functions/public/createSession": typeof functions_public_createSession; "functions/public/endSession": typeof functions_public_endSession; "functions/public/generateReply": typeof functions_public_generateReply; @@ -60,8 +65,10 @@ declare const fullApi: ApiFromModules<{ "functions/public/index": typeof functions_public_index; "functions/public/sendMessage": typeof functions_public_sendMessage; "functions/public/trackEvent": typeof functions_public_trackEvent; + "functions/public/validateEmbedToken": typeof functions_public_validateEmbedToken; kbanalytics: typeof kbanalytics; knowledge: typeof knowledge; + "lib/security": typeof lib_security; migrations: typeof migrations; modelproviders: typeof modelproviders; monitor: typeof monitor; @@ -69,6 +76,7 @@ declare const fullApi: ApiFromModules<{ playground: typeof playground; public: typeof public_; rag: typeof rag; + secrets: typeof secrets; testing: typeof testing; webchat: typeof webchat; websitescraper: typeof websitescraper; diff --git a/packages/backend/convex/ai.ts b/packages/backend/convex/ai.ts index 1c79043..55f6644 100644 --- a/packages/backend/convex/ai.ts +++ b/packages/backend/convex/ai.ts @@ -7,10 +7,24 @@ import { mutation, } from "./_generated/server.js"; import { api, internal } from "./_generated/api.js"; -import { generateText, streamText } from "ai"; +import { generateText, streamText, tool, hasToolCall } from "ai"; +import { z } from "zod"; import type { Doc } from "./_generated/dataModel.js"; import { normalizeModelProvider } from "./modelproviders.js"; import { retrieveRagContext } from "./rag.js"; +import { + assertCanAccessResource, + assertIsOwner, + getTenantContext, + logAudit, + redactBotProfileSecrets, + requireBotProfile, + toPublicBotProfile, +} from "./lib/security.js"; +import { + decryptSecretFromStorage, + encryptSecretForStorage, +} from "./secrets.js"; type EscalationConfig = { enabled?: boolean; @@ -18,16 +32,13 @@ type EscalationConfig = { email?: string | null; }; -function buildEscalationPrompt(escalation?: EscalationConfig) { +function buildEscalationContactSection(escalation?: EscalationConfig) { if (!escalation?.enabled) return null; const whatsappDigits = (escalation.whatsapp || "").replace(/\D/g, ""); const email = (escalation.email || "").trim(); - // ✅ FIX: Allow escalation if at least ONE contact method is provided - if (!whatsappDigits && !email) { - return null; - } + if (!whatsappDigits && !email) return null; const contactLinks: string[] = []; if (whatsappDigits) { @@ -39,15 +50,68 @@ function buildEscalationPrompt(escalation?: EscalationConfig) { contactLinks.push(`[Email Us](${emailLink})`); } + return ["### Contact Us", ...contactLinks].join("\n"); +} + +function responseAlreadyContainsEscalation( + responseText: string, + escalation?: EscalationConfig, +) { + const whatsappDigits = (escalation?.whatsapp || "").replace(/\D/g, ""); + const email = (escalation?.email || "").trim(); + + const hasWhatsApp = whatsappDigits + ? responseText.includes(`https://wa.me/${whatsappDigits}`) + : true; + const hasEmail = email ? responseText.includes(`mailto:${email}`) : true; + return hasWhatsApp && hasEmail; +} + +/** Regex to strip any leaked tool-name text the AI might emit. */ +const TOOL_LEAK_RE = /trigger_escalation/gi; + +/** Default bridge sentence when escalation fires but text is empty/short. */ +const DEFAULT_BRIDGE_TEXT = + "I can connect you with our team for further assistance."; + +/** Sanitise response text: remove leaked tool name and trim whitespace. */ +function sanitizeToolLeak(text: string): { + sanitized: string; + leaked: boolean; +} { + if (!TOOL_LEAK_RE.test(text)) return { sanitized: text, leaked: false }; + return { + sanitized: text + .replace(TOOL_LEAK_RE, "") + .replace(/\s{2,}/g, " ") + .trim(), + leaked: true, + }; +} + +function buildEscalationPrompt(escalation?: EscalationConfig) { + if (!escalation?.enabled) return null; + + const whatsappDigits = (escalation.whatsapp || "").replace(/\D/g, ""); + const email = (escalation.email || "").trim(); + + if (!whatsappDigits && !email) { + return null; + } + return [ - "Escalation Protocol:", - "- When users ask about purchasing, pricing, contact information, speaking to sales, or need human assistance, you MUST include the contact section below.", - "- If you cannot answer from the Knowledge Base, you MUST provide the contact information.", - "- Do NOT make up contact information - ONLY use the links provided below.", - "- Do not add any other contact links anywhere in the response.", - "", - "### Contact Us", - ...contactLinks, + "Escalation Protocol (TOOL-BASED):", + "- PRIMARY RULE: If the Knowledge Base context contains a relevant, direct answer to the user's question, you MUST answer using it. Do not escalate in that case.", + "- You have access to a tool called `trigger_escalation`.", + "- When the user asks about purchasing, pricing, contact information, speaking to sales, or needs human assistance, you MUST call the `trigger_escalation` tool.", + "- When the user expresses frustration, anger, dissatisfaction, or repeatedly fails to get a satisfactory answer, you MUST call the `trigger_escalation` tool.", + "- If (and only if) the user asks for contact details / human support and you find contact details (phone numbers, emails, WhatsApp numbers) in the Knowledge Base context, you MUST call the `trigger_escalation` tool instead of outputting them as plain text.", + "- You are STRICTLY FORBIDDEN from outputting phone numbers, WhatsApp numbers, or email addresses in plain text, even if they exist in the Knowledge Base. ALWAYS use the `trigger_escalation` tool instead.", + "- DO NOT write 'trigger_escalation' as text. Just call the tool function.", + "- DO NOT say things like 'Sistem akan memunculkan tombol' or 'tombol akan muncul'. The UI is not your responsibility.", + "- If you are going to say you will connect the user to Admin/CS/Sales (or suggest pressing buttons), you MUST call the `trigger_escalation` tool instead of writing that as plain text.", + "- Before calling the tool, generate a short, polite bridge sentence (e.g., 'I can connect you with our team for further assistance.').", + "- Do NOT make up contact information.", ].join("\n"); } @@ -72,14 +136,66 @@ export const logAIResponse = internalMutation({ success: v.boolean(), errorMessage: v.optional(v.string()), integration: v.string(), + + // Optional but critical for production debugging/billing + toolCalls: v.optional(v.array(v.any())), + promptTokens: v.optional(v.number()), + completionTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), }, handler: async (ctx, args) => { - const logId = await ctx.db.insert("aiLogs", { - ...args, - createdAt: Date.now(), - }); - console.log("[logAIResponse] ✓ Logged AI response with ID:", logId); - return logId; + const botProfile = await ctx.db.get(args.botId); + + const auditUserId = botProfile?.user_id ?? "system"; + const auditOrgId = botProfile?.organization_id; + + let auditLogged = false; + try { + const logId = await ctx.db.insert("aiLogs", { + ...args, + // Ensure dashboard queries can filter reliably by owner + user_id: botProfile?.user_id, + organization_id: botProfile?.organization_id, + createdAt: Date.now(), + }); + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "log_ai_response", + resource_type: "aiLog", + resource_id: String(logId), + status: "success", + changes: { + before: null, + after: { + botId: args.botId, + conversationId: args.conversationId, + provider: args.provider, + model: args.model, + success: args.success, + }, + }, + }); + auditLogged = true; + + console.log("[logAIResponse] ✓ Logged AI response with ID:", logId); + return logId; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "log_ai_response", + resource_type: "aiLog", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -90,7 +206,7 @@ export const logAIResponse = internalMutation({ * Used by: generateBotResponse action for "widget" and unknown integrations * Parameters: conversationId, botResponse * - * Why internal mutation? + * W hy internal mutation? * - Public actions can't directly access ctx.db * - This bypasses auth checks (safe because it's server-side only) * - Simplest way to save messages for public widget @@ -101,18 +217,58 @@ export const saveBotMessage = internalMutation({ botResponse: v.string(), }, handler: async (ctx, args) => { - const msgId = await ctx.db.insert("messages", { - conversation_id: args.conversationId, - role: "bot", - content: args.botResponse, - created_at: Date.now(), - // No user_id for public widget (visitor-based) - }); + const conversation = await ctx.db.get(args.conversationId); + const auditUserId = conversation?.user_id ?? "system"; + const auditOrgId = (conversation as any)?.organization_id as + | string + | undefined; - console.log( - `[saveBotMessage] ✓ Saved bot message - conversationId: ${args.conversationId}, msgId: ${msgId}`, - ); - return msgId; + let auditLogged = false; + try { + const msgId = await ctx.db.insert("messages", { + conversation_id: args.conversationId, + role: "bot", + content: args.botResponse, + created_at: Date.now(), + // No user_id for public widget (visitor-based) + }); + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "save_bot_message", + resource_type: "message", + resource_id: String(msgId), + status: "success", + changes: { + before: null, + after: { + conversationId: args.conversationId, + role: "bot", + }, + }, + }); + auditLogged = true; + + console.log( + `[saveBotMessage] ✓ Saved bot message - conversationId: ${args.conversationId}, msgId: ${msgId}`, + ); + return msgId; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "save_bot_message", + resource_type: "message", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -136,42 +292,125 @@ export const getConversationMessages = internalQuery({ }, }); -// ===== NEW: STREAMING HELPERS ===== +/** + * Internal Query: Get bot profile by ID + * Used by: getRagContextForStream action for database access + * Security: This is called from getRagContextForStream which performs auth checks + */ +export const getBotProfileById = internalQuery({ + args: { + botId: v.id("botProfiles"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.botId); + }, +}); /** - * Query: Fetch bot configuration for streaming - * Used by: Next.js /api/chat/stream route - * Returns: Model, provider, API key, system prompt, temperature + * Internal Query: Get conversation by ID + * Used by: getRagContextForStream action for database access + * Security: This is called from getRagContextForStream which performs auth checks */ -export const getBotConfigForStream = query({ +export const getConversationById = internalQuery({ + args: { + conversationId: v.id("conversations"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.conversationId); + }, +}); + +/** + * Internal Query: Resolve tenant context (userId/orgId/orgRole). + * + * Actions don't have direct `ctx.db`, so they can call this internal query + * to enforce org-aware authorization using shared helpers. + */ +export const getTenantContextInternal = internalQuery({ + args: {}, + handler: async (ctx) => { + return await getTenantContext(ctx); + }, +}); + +// ===== GAP #2: SAFE BOT CONFIG SURFACES ===== + +/** + * Query: Public bot profile config (NO credentials). + * + * AuthZ: Owner or org member (role-based via `orgMembers`). + */ +export const getBotConfigPublic = query({ args: { botId: v.id("botProfiles"), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: Must be logged in"); - } + const tenant = await getTenantContext(ctx); + const bot = await requireBotProfile(ctx, args.botId); + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + return toPublicBotProfile(bot); + }, +}); - const userId = identity.subject; - const orgId = (identity.org_id as string | undefined) || undefined; +/** + * Query: Admin bot profile config (redacted secrets). + * + * Intentionally DOES NOT return api_key. + * AuthZ: Owner or org member (role-based via `orgMembers`). + */ +export const getBotConfigForAdmin = query({ + args: { + botId: v.id("botProfiles"), + }, + handler: async (ctx, args) => { + const tenant = await getTenantContext(ctx); + const bot = await requireBotProfile(ctx, args.botId); + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + return redactBotProfileSecrets(bot); + }, +}); - const botProfile = await ctx.db.get(args.botId); +/** + * Action: Fetch bot configuration for streaming (server-only, returns api_key). + * + * This is designed for server environments (e.g. Next.js route handlers) and + * requires `CONVEX_SERVER_SHARED_SECRET` to be set and provided by the caller. + */ +export const getBotConfigForStream: ReturnType = action({ + args: { + botId: v.id("botProfiles"), + serverSecret: v.string(), + }, + handler: async (ctx, args) => { + const expected = process.env.CONVEX_SERVER_SHARED_SECRET; + if (!expected) { + throw new Error( + "Server misconfigured: CONVEX_SERVER_SHARED_SECRET is not set", + ); + } + if (args.serverSecret !== expected) { + throw new Error("Unauthorized: Invalid server secret"); + } + + const tenant = await ctx.runQuery(internal.ai.getTenantContextInternal, {}); + const botProfile = await ctx.runQuery(internal.ai.getBotProfileById, { + botId: args.botId, + }); if (!botProfile) return null; - const isOwner = botProfile.user_id === userId; - const isOrgMatch = Boolean(orgId) && botProfile.organization_id === orgId; + assertCanAccessResource( + botProfile, + tenant, + "Unauthorized: Cannot access other user's bot", + ); - if (!isOwner && !isOrgMatch) { - throw new Error("Unauthorized: Cannot access other user's bot"); - } + const apiKey = await decryptSecretFromStorage(botProfile.api_key || null); - // Return only what the streaming route needs (avoid leaking full profile) return { id: botProfile._id, model_provider: botProfile.model_provider || null, model_id: botProfile.model_id || null, - api_key: botProfile.api_key || null, + api_key: apiKey, system_prompt: botProfile.system_prompt || null, temperature: botProfile.temperature ?? null, max_tokens: botProfile.max_tokens ?? null, @@ -184,6 +423,87 @@ export const getBotConfigForStream = query({ }, }); +/** + * Mutation: Rotate bot API key (owner-only). + */ +export const rotateApiKey = mutation({ + args: { + botId: v.id("botProfiles"), + newApiKey: v.string(), + }, + handler: async (ctx, args) => { + const tenant = await getTenantContext(ctx); + const bot = await requireBotProfile(ctx, args.botId); + try { + assertIsOwner(bot, tenant, "Unauthorized: Not bot owner"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "rotate_api_key", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const newApiKey = args.newApiKey.trim(); + if (!newApiKey) { + throw new Error("newApiKey is required"); + } + + const encrypted = await encryptSecretForStorage(newApiKey); + + const before = bot; + const afterPatch = { + api_key: encrypted, + _encrypted_api_key: encrypted, + updated_at: Date.now(), + }; + + let auditLogged = false; + try { + await ctx.db.patch(args.botId, afterPatch); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "rotate_api_key", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "success", + changes: { + before, + after: { ...before, ...afterPatch }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "rotate_api_key", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } + + return { success: true, id: args.botId }; + }, +}); + +// ===== STREAMING HELPERS ===== + /** * Query: Fetch conversation history for streaming * Used by: Next.js /api/chat/stream route @@ -254,6 +574,14 @@ export const saveStreamedResponse = mutation({ handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "save_streamed_response", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); throw new Error("Unauthorized: Must be logged in"); } @@ -269,6 +597,15 @@ export const saveStreamedResponse = mutation({ const isOrgMatch = Boolean(orgId) && botProfile.organization_id === orgId; if (!isOwner && !isOrgMatch) { + await logAudit(ctx, { + user_id: userId, + organization_id: orgId, + action: "save_streamed_response", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "denied", + error_message: "Unauthorized: Cannot access other user's bot", + }); throw new Error("Unauthorized: Cannot access other user's bot"); } @@ -278,42 +615,87 @@ export const saveStreamedResponse = mutation({ } if (conversation.bot_id !== args.botId) { + await logAudit(ctx, { + user_id: userId, + organization_id: orgId, + action: "save_streamed_response", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "denied", + error_message: "Unauthorized: Conversation does not belong to bot", + }); throw new Error("Unauthorized: Conversation does not belong to bot"); } const integration = args.integration || "streaming"; - // Save message to database - const msgId = await ctx.db.insert("messages", { - user_id: userId, - conversation_id: args.conversationId, - role: "bot", - content: args.botResponse, - created_at: Date.now(), - }); + let auditLogged = false; + try { + // Save message to database + const msgId = await ctx.db.insert("messages", { + user_id: userId, + conversation_id: args.conversationId, + role: "bot", + content: args.botResponse, + created_at: Date.now(), + }); - // Log metrics with streaming-specific fields - const logId = await ctx.db.insert("aiLogs", { - user_id: userId, - botId: args.botId, - conversationId: args.conversationId, - userMessage: args.userMessage, - botResponse: args.botResponse, - model: args.model, - provider: args.provider, - temperature: 0.7, - executionTimeMs: args.executionTimeMs, - knowledgeChunksRetrieved: args.knowledgeChunksRetrieved, - contextUsed: "", - success: true, - integration, - createdAt: Date.now(), - }); + // Log metrics with streaming-specific fields + const logId = await ctx.db.insert("aiLogs", { + user_id: userId, + botId: args.botId, + conversationId: args.conversationId, + userMessage: args.userMessage, + botResponse: args.botResponse, + model: args.model, + provider: args.provider, + temperature: 0.7, + executionTimeMs: args.executionTimeMs, + knowledgeChunksRetrieved: args.knowledgeChunksRetrieved, + contextUsed: "", + success: true, + integration, + createdAt: Date.now(), + }); - console.log( - `[saveStreamedResponse] ✓ Saved streamed response - msgId: ${msgId}, logId: ${logId}`, - ); - return { msgId, logId }; + await logAudit(ctx, { + user_id: userId, + organization_id: botProfile.organization_id ?? orgId, + action: "save_streamed_response", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "success", + changes: { + before: null, + after: { + botId: args.botId, + msgId, + aiLogId: logId, + }, + }, + }); + auditLogged = true; + + console.log( + `[saveStreamedResponse] ✓ Saved streamed response - msgId: ${msgId}, logId: ${logId}`, + ); + return { msgId, logId }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: userId, + organization_id: botProfile.organization_id ?? orgId, + action: "save_streamed_response", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -324,13 +706,54 @@ export const createStreamingBotMessage = internalMutation({ conversationId: v.id("conversations"), }, handler: async (ctx, args) => { - const msgId = await ctx.db.insert("messages", { - conversation_id: args.conversationId, - role: "bot", - content: "", - created_at: Date.now(), - }); - return msgId; + const conversation = await ctx.db.get(args.conversationId); + const auditUserId = conversation?.user_id ?? "system"; + const auditOrgId = (conversation as any)?.organization_id as + | string + | undefined; + + let auditLogged = false; + try { + const msgId = await ctx.db.insert("messages", { + conversation_id: args.conversationId, + user_id: conversation?.user_id, + visitor_id: conversation?.visitor_id, + participant_id: (conversation as any)?.participant_id, + role: "bot", + content: "", + created_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "create_streaming_bot_message", + resource_type: "message", + resource_id: String(msgId), + status: "success", + changes: { + before: null, + after: { conversationId: args.conversationId }, + }, + }); + auditLogged = true; + + return msgId; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "create_streaming_bot_message", + resource_type: "message", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -340,18 +763,88 @@ export const updateStreamingBotMessage = internalMutation({ content: v.string(), }, handler: async (ctx, args) => { - await ctx.db.patch(args.messageId, { - content: args.content, + const before = await ctx.db.get(args.messageId); + const auditUserId = (before as any)?.user_id ?? "system"; + const auditOrgId = (before as any)?.organization_id as string | undefined; + try { + await ctx.db.patch(args.messageId, { + content: args.content, + }); + return { success: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Intentionally do NOT log every streaming update as a success audit event. + // Streaming updates can happen hundreds/thousands of times per message and would + // quickly explode the auditLogs table. We only log errors here (rare) and log a + // single completion audit entry from the streaming action. + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "update_streaming_bot_message", + resource_type: "message", + resource_id: String(args.messageId), + status: "error", + error_message: errorMessage, + }); + throw error; + } + }, +}); + +export const logStreamingBotMessageCompletion = internalMutation({ + args: { + messageId: v.id("messages"), + status: v.union(v.literal("success"), v.literal("error")), + error_message: v.optional(v.string()), + chunks: v.optional(v.number()), + final_length: v.optional(v.number()), + execution_time_ms: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const message = await ctx.db.get(args.messageId); + const conversation = message + ? await ctx.db.get(message.conversation_id) + : null; + + const auditUserId = + (conversation as any)?.user_id ?? (message as any)?.user_id ?? "system"; + const auditOrgId = (conversation as any)?.organization_id as + | string + | undefined; + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "complete_streaming_bot_message", + resource_type: "message", + resource_id: String(args.messageId), + status: args.status, + error_message: args.error_message, + changes: { + before: null, + after: { + chunks: args.chunks, + final_length: args.final_length, + execution_time_ms: args.execution_time_ms, + }, + }, }); + return { success: true }; }, }); /** - * Query: Retrieve RAG context for dashboard streaming route - * Used by: apps/web/app/api/chat/stream/route.ts + * Action: Retrieve RAG context for dashboard streaming route + * ✅ FIXED: Changed from query to action (was violating Convex query/action boundary) + * Reason: retrieveRagContext calls generateEmbedding which performs network I/O via embed() + * Queries cannot perform network I/O; only actions and mutations can + * Note: Uses ctx.runQuery for database access since actions don't have ctx.db + * Used by: apps/web/app/api/chat/stream/route.ts (call via convex.action instead of convex.query) */ -export const getRagContextForStream = query({ +export const getRagContextForStream = action({ args: { botId: v.id("botProfiles"), conversationId: v.id("conversations"), @@ -366,7 +859,11 @@ export const getRagContextForStream = query({ const userId = identity.subject; const orgId = (identity.org_id as string | undefined) || undefined; - const botProfile = await ctx.db.get(args.botId); + // Use ctx.runQuery for database access in actions + const botProfile = await ctx.runQuery(internal.ai.getBotProfileById, { + botId: args.botId, + }); + if (!botProfile) { throw new Error("Bot not found"); } @@ -377,7 +874,10 @@ export const getRagContextForStream = query({ throw new Error("Unauthorized: Cannot access other user's bot"); } - const conversation = await ctx.db.get(args.conversationId); + const conversation = await ctx.runQuery(internal.ai.getConversationById, { + conversationId: args.conversationId, + }); + if (!conversation) { throw new Error("Conversation not found"); } @@ -385,9 +885,13 @@ export const getRagContextForStream = query({ throw new Error("Unauthorized: Conversation does not belong to bot"); } + const decryptedApiKey = await decryptSecretFromStorage( + botProfile.api_key || null, + ); + const botConfig = { model_provider: botProfile.model_provider || null, - api_key: botProfile.api_key || null, + api_key: decryptedApiKey, }; try { @@ -665,6 +1169,10 @@ export const generateBotResponse = action({ "[generateBotResponse] STEP 5: Preparing generateText parameters...", ); let botResponseText = ""; + let toolCallsForLog: any[] | undefined; + let promptTokensForLog: number | undefined; + let completionTokensForLog: number | undefined; + let totalTokensForLog: number | undefined; try { const messagesForAI = [ @@ -700,14 +1208,66 @@ export const generateBotResponse = action({ ); console.log("[generateBotResponse] Calling generateText()..."); + // Build the trigger_escalation tool (only if escalation is enabled & configured) + const escalationContactSection = buildEscalationContactSection( + botConfig.escalation, + ); + const tools = escalationContactSection + ? { + trigger_escalation: tool({ + description: + "Call this tool whenever the user asks for support, sales, human contact, or expresses frustration/anger. Also use this tool if you find contact details in the Context/Knowledge Base that answer the user's request.", + inputSchema: z.object({}), + }), + } + : undefined; + const result = await generateText({ model, system: finalSystemPrompt, messages: messagesForAI, temperature: botConfig.temperature ?? 0.7, + tools, + stopWhen: tools ? hasToolCall("trigger_escalation") : undefined, }); + const usage = (result as any).usage; + promptTokensForLog = usage?.promptTokens; + completionTokensForLog = usage?.completionTokens; + totalTokensForLog = usage?.totalTokens; + + toolCallsForLog = (result as any).steps + ? ((result as any).steps as any[]) + .flatMap((step) => step?.toolCalls ?? []) + .filter(Boolean) + : undefined; + botResponseText = result.text; + + // Sanitize: remove any leaked tool name from the response text + const { sanitized, leaked: toolNameLeaked } = + sanitizeToolLeak(botResponseText); + botResponseText = sanitized; + + // Check if the AI called the trigger_escalation tool + const escalationToolCalled = result.steps?.some((step: any) => + step.toolCalls?.some((tc: any) => tc.toolName === "trigger_escalation"), + ); + + if ( + (escalationToolCalled || toolNameLeaked) && + escalationContactSection && + !responseAlreadyContainsEscalation( + botResponseText, + botConfig.escalation, + ) + ) { + // If the remaining text is empty/too short, prepend a bridge sentence + if (botResponseText.trim().length < 5) { + botResponseText = DEFAULT_BRIDGE_TEXT; + } + botResponseText = `${botResponseText}\n\n${escalationContactSection}`; + } console.log( `[generateBotResponse] ✓ AI response generated successfully (${botResponseText.length} chars)`, ); @@ -739,6 +1299,10 @@ export const generateBotResponse = action({ success: false, errorMessage: errorMessage, integration, + toolCalls: toolCallsForLog, + promptTokens: promptTokensForLog, + completionTokens: completionTokensForLog, + totalTokens: totalTokensForLog, }); } catch (logError) { console.warn("[generateBotResponse] Failed to log error metrics"); @@ -824,6 +1388,10 @@ export const generateBotResponse = action({ contextUsed: contextBlock, success: true, integration, + toolCalls: toolCallsForLog, + promptTokens: promptTokensForLog, + completionTokens: completionTokensForLog, + totalTokens: totalTokensForLog, }); console.log( `[generateBotResponse] ✓ Metrics logged (${executionTimeMs}ms execution time)`, @@ -1013,8 +1581,27 @@ export const generateBotResponseStream = action({ ); let fullResponseText = ""; + let streamChunkCount = 0; + let streamToolCallsForLog: any[] | undefined; + let streamPromptTokensForLog: number | undefined; + let streamCompletionTokensForLog: number | undefined; + let streamTotalTokensForLog: number | undefined; try { - const { textStream } = await streamText({ + // Build the trigger_escalation tool (only if escalation is enabled & configured) + const escalationContactSection = buildEscalationContactSection( + botConfig.escalation, + ); + const tools = escalationContactSection + ? { + trigger_escalation: tool({ + description: + "Call this tool whenever the user asks for support, sales, human contact, or expresses frustration/anger. Also use this tool if you find contact details in the Context/Knowledge Base that answer the user's request.", + inputSchema: z.object({}), + }), + } + : undefined; + + const streamResult = await streamText({ model, system: finalSystemPrompt, messages: [ @@ -1022,10 +1609,61 @@ export const generateBotResponseStream = action({ { role: "user" as const, content: userMessage }, ], temperature: botConfig.temperature ?? 0.7, + tools, + stopWhen: tools ? hasToolCall("trigger_escalation") : undefined, }); + const { textStream, steps } = streamResult as any; + const usage = (streamResult as any).usage; + streamPromptTokensForLog = usage?.promptTokens; + streamCompletionTokensForLog = usage?.completionTokens; + streamTotalTokensForLog = usage?.totalTokens; + + let toolNameLeaked = false; + for await (const delta of textStream as AsyncIterable) { fullResponseText += delta; + streamChunkCount += 1; + + // Detect & sanitize leaked tool name in the accumulated text + if (TOOL_LEAK_RE.test(fullResponseText)) { + toolNameLeaked = true; + fullResponseText = fullResponseText + .replace(TOOL_LEAK_RE, "") + .replace(/\s{2,}/g, " ") + .trim(); + } + + await ctx.runMutation(internal.ai.updateStreamingBotMessage, { + messageId, + content: fullResponseText, + }); + } + + // Check if the AI called the trigger_escalation tool + const resolvedSteps = await steps; + streamToolCallsForLog = resolvedSteps + ? (resolvedSteps as any[]) + .flatMap((step) => step?.toolCalls ?? []) + .filter(Boolean) + : undefined; + const escalationToolCalled = resolvedSteps?.some((step: any) => + step.toolCalls?.some((tc: any) => tc.toolName === "trigger_escalation"), + ); + + if ( + (escalationToolCalled || toolNameLeaked) && + escalationContactSection && + !responseAlreadyContainsEscalation( + fullResponseText, + botConfig.escalation, + ) + ) { + // If the remaining text is empty/too short, prepend a bridge sentence + if (fullResponseText.trim().length < 5) { + fullResponseText = DEFAULT_BRIDGE_TEXT; + } + fullResponseText = `${fullResponseText}\n\n${escalationContactSection}`; await ctx.runMutation(internal.ai.updateStreamingBotMessage, { messageId, content: fullResponseText, @@ -1036,6 +1674,20 @@ export const generateBotResponseStream = action({ error instanceof Error ? error.message : String(error); const executionTimeMs = Date.now() - startTime; + + try { + await ctx.runMutation(internal.ai.logStreamingBotMessageCompletion, { + messageId, + status: "error", + error_message: errorMessage, + chunks: streamChunkCount, + final_length: fullResponseText.length, + execution_time_ms: executionTimeMs, + }); + } catch { + // ignore + } + try { await ctx.runMutation(internal.ai.logAIResponse, { botId, @@ -1051,6 +1703,10 @@ export const generateBotResponseStream = action({ success: false, errorMessage, integration, + toolCalls: streamToolCallsForLog, + promptTokens: streamPromptTokensForLog, + completionTokens: streamCompletionTokensForLog, + totalTokens: streamTotalTokensForLog, }); } catch { // ignore @@ -1078,6 +1734,22 @@ export const generateBotResponseStream = action({ contextUsed: contextBlock, success: true, integration, + toolCalls: streamToolCallsForLog, + promptTokens: streamPromptTokensForLog, + completionTokens: streamCompletionTokensForLog, + totalTokens: streamTotalTokensForLog, + }); + } catch { + // ignore + } + + try { + await ctx.runMutation(internal.ai.logStreamingBotMessageCompletion, { + messageId, + status: "success", + chunks: streamChunkCount, + final_length: fullResponseText.length, + execution_time_ms: executionTimeMs, }); } catch { // ignore diff --git a/packages/backend/convex/aiAnalytics.ts b/packages/backend/convex/aiAnalytics.ts index 915aed0..f92f3ec 100644 --- a/packages/backend/convex/aiAnalytics.ts +++ b/packages/backend/convex/aiAnalytics.ts @@ -62,10 +62,29 @@ export const getAIMetrics = query({ const modelsUsed = [...new Set(logs.map((l) => l.model))]; - const totalTokensGenerated = logs.reduce( - (sum, l) => sum + l.botResponse.split(/\s+/).length, - 0, - ); + const totalTokensGenerated = logs.reduce((sum, l) => { + const anyLog = l as any; + if (typeof anyLog.totalTokens === "number") + return sum + anyLog.totalTokens; + if ( + typeof anyLog.promptTokens === "number" && + typeof anyLog.completionTokens === "number" + ) { + return sum + anyLog.promptTokens + anyLog.completionTokens; + } + // Fallback (legacy logs): estimate tokens from word count + return sum + l.botResponse.split(/\s+/).length; + }, 0); + + const totalPromptTokens = logs.reduce((sum, l) => { + const v = (l as any).promptTokens; + return sum + (typeof v === "number" ? v : 0); + }, 0); + + const totalCompletionTokens = logs.reduce((sum, l) => { + const v = (l as any).completionTokens; + return sum + (typeof v === "number" ? v : 0); + }, 0); const totalContextCharacters = logs.reduce( (sum, l) => sum + l.contextUsed.length, @@ -84,6 +103,8 @@ export const getAIMetrics = query({ avgExecutionTimeMs: Math.round(avgExecutionTimeMs), modelsUsed, totalTokensGenerated, + totalPromptTokens, + totalCompletionTokens, totalContextCharacters, errors: failed.map((f) => ({ message: f.errorMessage || "Unknown error", @@ -97,6 +118,85 @@ export const getAIMetrics = query({ }, }); +function estimateTokensFromLog(log: any): number { + if (typeof log?.totalTokens === "number") return log.totalTokens; + if ( + typeof log?.promptTokens === "number" && + typeof log?.completionTokens === "number" + ) { + return log.promptTokens + log.completionTokens; + } + if (typeof log?.botResponse === "string") { + return log.botResponse.split(/\s+/).length; + } + return 0; +} + +/** + * Time-series performance data for Overview chart. + * Returns buckets with { time, calls, tokens }. + */ +export const getAIPerformanceSeries = query({ + args: { + botId: v.id("botProfiles"), + days: v.number(), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Unauthorized: Must be logged in"); + } + + const userId = identity.subject; + const now = Date.now(); + const start = now - args.days * 24 * 60 * 60 * 1000; + + const logs = await ctx.db + .query("aiLogs") + .withIndex("by_user_createdAt", (q) => + q.eq("user_id", userId).gte("createdAt", start), + ) + .collect() + .then((allLogs) => allLogs.filter((l) => l.botId === args.botId)); + + const isHourly = args.days <= 1; + const bucketMs = isHourly ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + const bucketCount = isHourly ? 24 : Math.max(1, Math.floor(args.days)); + const alignedStart = now - bucketCount * bucketMs; + + const buckets = Array.from({ length: bucketCount }, (_, i) => { + const bucketStart = alignedStart + i * bucketMs; + const time = isHourly + ? new Date(bucketStart).toLocaleTimeString("en-US", { hour: "numeric" }) + : new Date(bucketStart).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + + return { + bucketStart, + time, + calls: 0, + tokens: 0, + }; + }); + + for (const log of logs) { + const createdAt = (log as any).createdAt; + if (typeof createdAt !== "number") continue; + const idx = Math.floor((createdAt - alignedStart) / bucketMs); + if (idx < 0 || idx >= buckets.length) continue; + const bucket = buckets[idx]; + if (bucket) { + bucket.calls += 1; + bucket.tokens += estimateTokensFromLog(log); + } + } + + return buckets.map(({ time, calls, tokens }) => ({ time, calls, tokens })); + }, +}); + /** * Get recent AI logs for detailed inspection * ✅ Automatically filtered to current user's logs only diff --git a/packages/backend/convex/analytics.ts b/packages/backend/convex/analytics.ts index e5ca161..dc8a4a1 100644 --- a/packages/backend/convex/analytics.ts +++ b/packages/backend/convex/analytics.ts @@ -91,7 +91,7 @@ export const getDashboardStats: RegisteredQuery< }); /** - * Query: Get lead capture stats for a bot over a time period + * Query: Get lead capture stats for a bot (all-time) * ✅ Filtered to current user's bot */ export const getLeadStats = query({ @@ -120,12 +120,12 @@ export const getLeadStats = query({ throw new Error("Unauthorized: Cannot access other user's bot"); } - const cutoff = Date.now() - args.days * 24 * 60 * 60 * 1000; - const events = await ctx.db .query("businessEvents") .withIndex("by_bot_createdAt", (q) => - q.eq("botId", args.botId).gte("createdAt", cutoff), + // Note: Lead capture is currently aggregated all-time (no cutoff). + // `days` remains in the args for backwards compatibility with existing callers. + q.eq("botId", args.botId), ) .collect(); diff --git a/packages/backend/convex/configuration.ts b/packages/backend/convex/configuration.ts index e7e6667..4e3da04 100644 --- a/packages/backend/convex/configuration.ts +++ b/packages/backend/convex/configuration.ts @@ -1,5 +1,10 @@ import { v } from "convex/values"; import { query, mutation, internalQuery } from "./_generated/server.js"; +import { logAudit } from "./lib/security.js"; +import { + decryptSecretFromStorage, + encryptSecretForStorage, +} from "./secrets.js"; // ===== CONFIGURATION ===== @@ -25,11 +30,13 @@ export const getBotConfig: ReturnType = query({ return null; } + const hasApiKey = Boolean(profile.api_key); + return { id: profile._id, model_provider: profile.model_provider || null, model_id: profile.model_id || null, - api_key: profile.api_key || null, + has_api_key: hasApiKey, system_prompt: profile.system_prompt || null, temperature: profile.temperature ?? null, max_tokens: profile.max_tokens ?? null, @@ -56,11 +63,13 @@ export const getBotConfigByBotId = internalQuery({ const profile = await ctx.db.get(args.botId); if (!profile) return null; + const apiKey = await decryptSecretFromStorage(profile.api_key || null); + return { id: profile._id, model_provider: profile.model_provider || null, model_id: profile.model_id || null, - api_key: profile.api_key || null, + api_key: apiKey, system_prompt: profile.system_prompt || null, temperature: profile.temperature ?? null, max_tokens: profile.max_tokens ?? null, @@ -100,6 +109,13 @@ export const updateBotConfig: ReturnType = mutation({ handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_bot_config", + resource_type: "botProfile", + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); throw new Error("Unauthorized: Must be logged in"); } @@ -111,6 +127,15 @@ export const updateBotConfig: ReturnType = mutation({ .first(); if (!profile) { + await logAudit(ctx, { + user_id: userId, + organization_id: (identity.org_id as string | undefined) || undefined, + action: "update_bot_config", + resource_type: "botProfile", + status: "error", + error_message: + "Bot profile not found. Visit Webchat → Bot Profile once to initialize your bot.", + }); throw new Error( "Bot profile not found. Visit Webchat → Bot Profile once to initialize your bot.", ); @@ -135,7 +160,14 @@ export const updateBotConfig: ReturnType = mutation({ // Distinguish undefined (not sent) from null (explicitly cleared) if (model_provider !== undefined) updates.model_provider = model_provider; if (model_id !== undefined) updates.model_id = model_id; - if (api_key !== undefined) updates.api_key = api_key; + if (api_key !== undefined) { + if (api_key === null) { + updates.api_key = null; + } else { + const encrypted = await encryptSecretForStorage(api_key); + updates.api_key = encrypted; + } + } if (system_prompt !== undefined) updates.system_prompt = system_prompt; // Smart defaults: if not in advanced mode, ensure defaults are set @@ -158,8 +190,42 @@ export const updateBotConfig: ReturnType = mutation({ if (max_tokens !== undefined) updates.max_tokens = max_tokens; } - // Update the profile - await ctx.db.patch(profile._id, updates); + const before = profile; + + let auditLogged = false; + try { + // Update the profile + await ctx.db.patch(profile._id, updates); + + await logAudit(ctx, { + user_id: userId, + organization_id: profile.organization_id, + action: "update_bot_config", + resource_type: "botProfile", + resource_id: String(profile._id), + status: "success", + changes: { + before, + after: { ...before, ...updates }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: userId, + organization_id: profile.organization_id, + action: "update_bot_config", + resource_type: "botProfile", + resource_id: String(profile._id), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } return { success: true, @@ -181,6 +247,13 @@ export const updateEscalationConfig: ReturnType = mutation({ handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_escalation_config", + resource_type: "botProfile", + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); throw new Error("Unauthorized: Must be logged in"); } @@ -192,6 +265,15 @@ export const updateEscalationConfig: ReturnType = mutation({ .first(); if (!profile) { + await logAudit(ctx, { + user_id: userId, + organization_id: (identity.org_id as string | undefined) || undefined, + action: "update_escalation_config", + resource_type: "botProfile", + status: "error", + error_message: + "Bot profile not found. Visit Webchat → Bot Profile once to initialize your bot.", + }); throw new Error( "Bot profile not found. Visit Webchat → Bot Profile once to initialize your bot.", ); @@ -210,14 +292,49 @@ export const updateEscalationConfig: ReturnType = mutation({ } } - await ctx.db.patch(profile._id, { + const before = profile; + const patch = { escalation: { enabled: args.enabled, whatsapp: whatsappRaw, email: emailRaw, }, updated_at: Date.now(), - }); + }; + + let auditLogged = false; + try { + await ctx.db.patch(profile._id, patch); + + await logAudit(ctx, { + user_id: userId, + organization_id: profile.organization_id, + action: "update_escalation_config", + resource_type: "botProfile", + resource_id: String(profile._id), + status: "success", + changes: { + before, + after: { ...before, ...patch }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: userId, + organization_id: profile.organization_id, + action: "update_escalation_config", + resource_type: "botProfile", + resource_id: String(profile._id), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } return { success: true, id: profile._id }; }, diff --git a/packages/backend/convex/functions/public/createSession.ts b/packages/backend/convex/functions/public/createSession.ts index 1ea247b..c3f4bd1 100644 --- a/packages/backend/convex/functions/public/createSession.ts +++ b/packages/backend/convex/functions/public/createSession.ts @@ -1,41 +1,14 @@ import { v } from "convex/values"; import { mutation } from "../../_generated/server.js"; import { api } from "../../_generated/api.js"; +import { + createVisitorSession, + logAudit, + requireValidEmbedToken, + requireBotProfile, +} from "../../lib/security.js"; import type { Id } from "../../_generated/dataModel.js"; -function getNextUtcMidnight(timestamp: number): number { - const date = new Date(timestamp); - return Date.UTC( - date.getUTCFullYear(), - date.getUTCMonth(), - date.getUTCDate() + 1, - 0, - 0, - 0, - 0, - ); -} - -function extractVisitorTimestamp(visitorId: string): number | null { - if (!visitorId.startsWith("visitor_")) { - return null; - } - const parts = visitorId.split("_"); - if (parts.length < 3) { - return null; - } - const timestamp = Number(parts[1]); - return Number.isFinite(timestamp) ? timestamp : null; -} - -function isVisitorExpired(createdAt: number, now: number): boolean { - if (!Number.isFinite(createdAt) || createdAt > now) { - return true; - } - const expiresAt = getNextUtcMidnight(createdAt); - return now >= expiresAt; -} - function generateVisitorId(now: number): string { return `visitor_${now}_${Math.random().toString(36).substr(2, 9)}`; } @@ -45,118 +18,90 @@ function generateVisitorId(now: number): string { * * No authentication required. * Validates organization_id and bot_id. - * Creates both a conversation (in DB) and a public session token. - * Returns session ID and visitor ID for subsequent requests. + * Creates a visitor sessionToken and a conversation. + * Returns { conversationId, sessionToken, expiresAt }. * * Used by: Public widget embed script initialization * Access: public (no auth required) - * Parameters: organizationId, botId, visitorId (optional, auto-generated if omitted) - * Returns: { sessionId, conversationId, visitorId } + * Parameters: organizationId, botId + * Returns: { conversationId, sessionToken, expiresAt } */ export const createSession = mutation({ args: { - organizationId: v.string(), - botId: v.string(), - visitorId: v.optional(v.string()), + token: v.string(), + currentDomain: v.optional(v.string()), }, handler: async ( ctx, args, ): Promise<{ - sessionId: string; conversationId: string; - visitorId: string; + sessionToken: string; + expiresAt: number; }> => { - // ✅ VALIDATION 1: Verify bot exists and belongs to organization - const botId = ctx.db.normalizeId("botProfiles", args.botId); - if (!botId) { - throw new Error("Bot not found or does not belong to this organization"); - } - const botProfile = await ctx.db.get(botId); - - if (!botProfile || botProfile.organization_id !== args.organizationId) { - throw new Error("Bot not found or does not belong to this organization"); - } - - // ✅ VALIDATION 2: Validate or generate visitor ID (midnight UTC TTL) - if (args.visitorId) { - const timestamp = extractVisitorTimestamp(args.visitorId); - if (!timestamp || isVisitorExpired(timestamp, Date.now())) { - throw new Error("Visitor ID expired; refresh required"); - } + let embedToken: Awaited> | null = + null; + let botProfile: Awaited> | null = null; + try { + embedToken = await requireValidEmbedToken(ctx, { + token: args.token, + currentDomain: args.currentDomain, + }); + botProfile = await requireBotProfile(ctx, embedToken.bot_id); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: "unauthenticated", + organization_id: undefined, + action: "create_public_session", + resource_type: "embedToken", + resource_id: "(redacted)", + status: "denied", + error_message: errorMessage, + }); + throw error; } - const visitorId = args.visitorId || generateVisitorId(Date.now()); + const now = Date.now(); + const visitorId = generateVisitorId(now); - if (args.visitorId) { - const existingSessions = await ctx.db - .query("publicSessions") - .withIndex("by_session_lookup", (q) => - q - .eq("organizationId", args.organizationId) - .eq("botId", args.botId) - .eq("visitorId", visitorId), - ) - .collect(); - - const activeSessions = existingSessions.filter( - (session) => session.status !== "ended", - ); - - if (activeSessions.length > 0) { - const latestSession = activeSessions.sort( - (a, b) => b.createdAt - a.createdAt, - )[0]; - - if (latestSession) { - const conversation = await ctx.db.get(latestSession.conversationId); - if (conversation && conversation.status !== "closed") { - return { - sessionId: latestSession._id, - conversationId: latestSession.conversationId, - visitorId, - }; - } - - if (latestSession.status !== "ended") { - await ctx.db.patch(latestSession._id, { - status: "ended", - endedAt: new Date().toISOString(), - }); - } - } - } - } + const { sessionToken, expiresAt } = await createVisitorSession(ctx, { + botId: botProfile._id, + visitorId, + now, + }); - // ✅ DELEGATION: Call internal createConversation (supports visitor_id) const conversationId: Id<"conversations"> = await ctx.runMutation( api.monitor.createConversation, { - bot_id: botId, - organization_id: args.organizationId, + bot_id: botProfile._id, integration: "embed", topic: "Visitor Chat", - visitor_id: visitorId, + sessionToken, }, ); - // ✅ CREATE: Public session record for stateless verification - const sessionId: Id<"publicSessions"> = await ctx.db.insert( - "publicSessions", - { - organizationId: args.organizationId, - botId: args.botId, - visitorId: visitorId, - conversationId, - createdAt: Date.now(), - status: "active", + await logAudit(ctx, { + user_id: `visitor:${visitorId}`, + organization_id: botProfile.organization_id, + action: "create_public_session", + resource_type: "conversation", + resource_id: String(conversationId), + status: "success", + changes: { + before: null, + after: { + botId: String(botProfile._id), + conversationId: String(conversationId), + }, }, - ); + }); return { - sessionId, conversationId, - visitorId, + sessionToken, + expiresAt, }; }, }); diff --git a/packages/backend/convex/functions/public/endSession.ts b/packages/backend/convex/functions/public/endSession.ts index 2e0c1dd..80b3960 100644 --- a/packages/backend/convex/functions/public/endSession.ts +++ b/packages/backend/convex/functions/public/endSession.ts @@ -1,7 +1,7 @@ import { v } from "convex/values"; import { mutation } from "../../_generated/server.js"; import { api } from "../../_generated/api.js"; -import type { Id } from "../../_generated/dataModel.js"; +import { requireValidVisitorSession, logAudit } from "../../lib/security.js"; /** * PUBLIC MUTATION: End a public chat session @@ -17,44 +17,51 @@ import type { Id } from "../../_generated/dataModel.js"; */ export const endSession = mutation({ args: { - sessionId: v.string(), - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + conversationId: v.string(), + sessionToken: v.string(), }, handler: async (ctx, args): Promise<{ success: boolean }> => { - const session: { - _id: Id<"publicSessions">; - conversationId: Id<"conversations">; - organizationId: string; - botId: string; - visitorId: string; - } | null = await ctx.runQuery(api.public.getSessionDetails, { - sessionId: args.sessionId, - organizationId: args.organizationId, - botId: args.botId, - visitorId: args.visitorId, + const conversationId = ctx.db.normalizeId( + "conversations", + args.conversationId, + ); + if (!conversationId) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "end_public_session", + resource_type: "conversation", + resource_id: args.conversationId, + status: "error", + error_message: "Conversation not found", + }); + throw new Error("Conversation not found"); + } + + await ctx.runMutation(api.monitor.closeConversation, { + conversationId, + sessionToken: args.sessionToken, }); - if (!session) { - throw new Error( - "Session not found or does not belong to this organization/bot", - ); - } + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); - // ✅ UPDATE: Mark session as ended (don't delete for admin review) await ctx.db.patch(session._id, { - status: "ended", - endedAt: new Date().toISOString(), + revoked: true, }); - const conversation = await ctx.db.get(session.conversationId); - if (conversation && conversation.status !== "closed") { - await ctx.db.patch(session.conversationId, { - status: "closed", - updated_at: Date.now(), - }); - } + await logAudit(ctx, { + user_id: `visitor:${session.visitor_id}`, + action: "end_public_session", + resource_type: "visitorSession", + resource_id: String(session._id), + status: "success", + changes: { + before: { _id: session._id, revoked: session.revoked }, + after: { _id: session._id, revoked: true }, + }, + }); return { success: true }; }, diff --git a/packages/backend/convex/functions/public/generateReply.ts b/packages/backend/convex/functions/public/generateReply.ts index 1852e0f..b4e2d17 100644 --- a/packages/backend/convex/functions/public/generateReply.ts +++ b/packages/backend/convex/functions/public/generateReply.ts @@ -27,10 +27,8 @@ import { api } from "../../_generated/api.js"; */ export const generateReply = action({ args: { - sessionId: v.string(), - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + conversationId: v.string(), + sessionToken: v.string(), userMessage: v.string(), }, handler: async ( @@ -43,40 +41,18 @@ export const generateReply = action({ provider?: string; error?: string; }> => { - const { sessionId, organizationId, botId, visitorId, userMessage } = args; + const { conversationId, sessionToken, userMessage } = args; - console.log( - `[generateReply] Starting - sessionId: ${sessionId}, botId: ${botId}, visitorId: ${visitorId}`, - ); - - // ✅ VALIDATION 1: Verify session exists and matches all IDs - const session: { - _id: string; - conversationId: string; - organizationId: string; - botId: string; - visitorId: string; - } | null = await ctx.runQuery(api.public.getSessionDetails, { - sessionId, - organizationId, - botId, - visitorId, + // ✅ VALIDATION: Verify conversation exists, is active, and belongs to sessionToken + const conversationStatus: { + exists: boolean; + isActive: boolean; + botId?: string; + } = await ctx.runQuery(api.public.getConversationStatus, { + conversationId, + sessionToken, }); - if (!session) { - const error = "Session validation failed - session not found or invalid"; - console.error(`[generateReply] ${error}`); - return { success: false, error }; - } - - // ✅ VALIDATION 2: Verify conversation still exists and is active - const conversationStatus = await ctx.runQuery( - api.public.getConversationStatus, - { - conversationId: session.conversationId, - }, - ); - if (!conversationStatus.exists) { const error = "Conversation not found"; console.error(`[generateReply] ${error}`); @@ -89,6 +65,10 @@ export const generateReply = action({ return { success: false, error }; } + if (!conversationStatus.botId) { + return { success: false, error: "Conversation bot not found" }; + } + // ✅ SECURITY: Use validated conversation_id and botId // Call the internal AI action with "widget" integration type try { @@ -103,8 +83,8 @@ export const generateReply = action({ provider?: string; error?: string; } = await ctx.runAction(api.ai.generateBotResponse, { - botId: botId as any, // botId is a string from widget, will be converted to Id type - conversationId: session.conversationId as any, // conversationId is validated but needs type casting + botId: conversationStatus.botId as any, + conversationId: conversationId as any, userMessage, integration: "widget", // Track widget-specific responses for analytics }); diff --git a/packages/backend/convex/functions/public/generateReplyStream.ts b/packages/backend/convex/functions/public/generateReplyStream.ts index 2c3e219..bbcbfcc 100644 --- a/packages/backend/convex/functions/public/generateReplyStream.ts +++ b/packages/backend/convex/functions/public/generateReplyStream.ts @@ -10,10 +10,8 @@ import { api } from "../../_generated/api.js"; */ export const generateReplyStream = action({ args: { - sessionId: v.string(), - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + conversationId: v.string(), + sessionToken: v.string(), userMessage: v.string(), }, handler: async ( @@ -26,34 +24,17 @@ export const generateReplyStream = action({ provider?: string; error?: string; }> => { - const { sessionId, organizationId, botId, visitorId, userMessage } = args; + const { conversationId, sessionToken, userMessage } = args; - // Validate session - const session: { - _id: string; - conversationId: string; - organizationId: string; - botId: string; - visitorId: string; - } | null = await ctx.runQuery(api.public.getSessionDetails, { - sessionId, - organizationId, - botId, - visitorId, + const conversationStatus: { + exists: boolean; + isActive: boolean; + botId?: string; + } = await ctx.runQuery(api.public.getConversationStatus, { + conversationId, + sessionToken, }); - if (!session) { - return { - success: false, - error: "Session validation failed - session not found or invalid", - }; - } - - const conversationStatus = await ctx.runQuery( - api.public.getConversationStatus, - { conversationId: session.conversationId }, - ); - if (!conversationStatus.exists) { return { success: false, error: "Conversation not found" }; } @@ -62,10 +43,14 @@ export const generateReplyStream = action({ return { success: false, error: "Conversation is closed" }; } + if (!conversationStatus.botId) { + return { success: false, error: "Conversation bot not found" }; + } + // Delegate to unified streaming generator return await ctx.runAction(api.ai.generateBotResponseStream, { - botId: botId as any, - conversationId: session.conversationId as any, + botId: conversationStatus.botId as any, + conversationId: conversationId as any, userMessage, integration: "widget", }); diff --git a/packages/backend/convex/functions/public/getBotProfile.ts b/packages/backend/convex/functions/public/getBotProfile.ts index a39fa73..da9eb6e 100644 --- a/packages/backend/convex/functions/public/getBotProfile.ts +++ b/packages/backend/convex/functions/public/getBotProfile.ts @@ -1,6 +1,9 @@ import { v } from "convex/values"; import { query } from "../../_generated/server.js"; -import { Id } from "../../_generated/dataModel.js"; +import { + requireBotProfile, + requireValidEmbedToken, +} from "../../lib/security.js"; /** * PUBLIC QUERY: Get bot profile for embed widget @@ -16,17 +19,16 @@ import { Id } from "../../_generated/dataModel.js"; */ export const getBotProfile = query({ args: { - organizationId: v.string(), - botId: v.string(), + token: v.string(), + currentDomain: v.optional(v.string()), }, handler: async (ctx, args) => { - const botProfile = await ctx.db.get(args.botId as Id<"botProfiles">); + const embedToken = await requireValidEmbedToken(ctx, { + token: args.token, + currentDomain: args.currentDomain, + }); + const botProfile = await requireBotProfile(ctx, embedToken.bot_id); - if (!botProfile || botProfile.organization_id !== args.organizationId) { - throw new Error("Bot not found or organization mismatch"); - } - - // ✅ RETURN: Full profile for widget configuration return { id: botProfile._id, organizationId: botProfile.organization_id, diff --git a/packages/backend/convex/functions/public/getConversationStatus.ts b/packages/backend/convex/functions/public/getConversationStatus.ts index 9ae58a2..813121c 100644 --- a/packages/backend/convex/functions/public/getConversationStatus.ts +++ b/packages/backend/convex/functions/public/getConversationStatus.ts @@ -1,5 +1,9 @@ import { v } from "convex/values"; import { query } from "../../_generated/server.js"; +import { + assertConversationOwnedByVisitorSession, + requireValidVisitorSession, +} from "../../lib/security.js"; /** * INTERNAL HELPER QUERY: Check conversation status @@ -15,17 +19,41 @@ import { query } from "../../_generated/server.js"; export const getConversationStatus = query({ args: { conversationId: v.string(), + sessionToken: v.string(), }, handler: async (ctx, args) => { - const conversation = await ctx.db.get(args.conversationId as any); + const conversationId = ctx.db.normalizeId( + "conversations", + args.conversationId, + ); + if (!conversationId) { + return { exists: false, isActive: false }; + } + + const conversation = await ctx.db.get(conversationId); if (!conversation) { return { exists: false, isActive: false }; } + try { + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); + + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); + } catch { + return { exists: false, isActive: false }; + } + return { exists: true, isActive: (conversation as any).status !== "closed", + botId: String(conversation.bot_id), }; }, }); diff --git a/packages/backend/convex/functions/public/getMessages.ts b/packages/backend/convex/functions/public/getMessages.ts index 99f1102..63050df 100644 --- a/packages/backend/convex/functions/public/getMessages.ts +++ b/packages/backend/convex/functions/public/getMessages.ts @@ -17,10 +17,8 @@ import type { Id } from "../../_generated/dataModel.js"; */ export const getMessages = query({ args: { - sessionId: v.string(), // v.id("publicSessions") - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + conversationId: v.string(), + sessionToken: v.string(), }, handler: async ( ctx, @@ -33,38 +31,21 @@ export const getMessages = query({ createdAt: number; }> > => { - const session: { - _id: Id<"publicSessions">; - conversationId: Id<"conversations">; - organizationId: string; - botId: string; - visitorId: string; - } | null = await ctx.runQuery(api.public.getSessionDetails, { - sessionId: args.sessionId, - organizationId: args.organizationId, - botId: args.botId, - visitorId: args.visitorId, - }); - - if (!session) { - throw new Error( - "Session not found or does not match provided organization/bot/visitor", - ); - } - - // ✅ VALIDATION 2: Verify conversation exists - const conversation = await ctx.db.get(session.conversationId); - if (!conversation) { + const conversationId = ctx.db.normalizeId( + "conversations", + args.conversationId, + ); + if (!conversationId) { throw new Error("Conversation not found"); } - // ✅ FETCH: All messages in conversation - const allMessages = await ctx.db - .query("messages") - .withIndex("by_conversation", (q) => - q.eq("conversation_id", session.conversationId), - ) - .collect(); + const allMessages = await ctx.runQuery( + api.monitor.getConversationMessages, + { + conversationId, + sessionToken: args.sessionToken, + }, + ); // ✅ RETURN: Formatted messages for widget return allMessages.map((msg) => ({ diff --git a/packages/backend/convex/functions/public/getSessionDetails.ts b/packages/backend/convex/functions/public/getSessionDetails.ts index 040ad4e..b0663a5 100644 --- a/packages/backend/convex/functions/public/getSessionDetails.ts +++ b/packages/backend/convex/functions/public/getSessionDetails.ts @@ -1,71 +1,55 @@ import { v } from "convex/values"; import { query } from "../../_generated/server.js"; import type { Id } from "../../_generated/dataModel.js"; +import { + assertConversationOwnedByVisitorSession, + requireValidVisitorSession, +} from "../../lib/security.js"; /** - * INTERNAL HELPER QUERY: Get session details with validation + * HELPER QUERY: Validate a sessionToken against a conversation * * Used by: generateReply action for session validation * This query verifies that all provided IDs match before returning session data * * Parameters: - * - sessionId: public session ID to retrieve - * - organizationId: must match the session's organization - * - botId: must match the session's bot - * - visitorId: must match the session's visitor + * - sessionToken: visitor session token + * - conversationId: conversation to validate * - * Returns: Session with conversationId if all validations pass, null otherwise + * Returns: Minimal session details if validation passes, null otherwise */ export const getSessionDetails = query({ args: { - sessionId: v.string(), - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + sessionToken: v.string(), + conversationId: v.string(), }, handler: async (ctx, args) => { - // Query publicSessions table using the by_session_lookup index - const sessions = await ctx.db - .query("publicSessions") - .withIndex("by_session_lookup", (q) => - q - .eq("organizationId", args.organizationId) - .eq("botId", args.botId) - .eq("visitorId", args.visitorId), - ) - .collect(); + const conversationId = ctx.db.normalizeId( + "conversations", + args.conversationId, + ); + if (!conversationId) return null; - // Find the specific session by ID - const session = sessions.find((s) => String(s._id) === args.sessionId); + const conversation = await ctx.db.get(conversationId); + if (!conversation) return null; - if (!session) { - return null; - } - - if (session.status === "ended") { - return null; - } + try { + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); - const conversation = await ctx.db.get(session.conversationId); - if (!conversation) { - return null; - } + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); - if ( - String(conversation.bot_id) !== args.botId || - conversation.organization_id !== args.organizationId || - conversation.visitor_id !== args.visitorId - ) { + return { + conversationId: conversation._id as Id<"conversations">, + botId: conversation.bot_id, + }; + } catch { return null; } - - // Return session with conversationId for use in AI generation - return { - _id: session._id as Id<"publicSessions">, - conversationId: session.conversationId as Id<"conversations">, - organizationId: session.organizationId, - botId: session.botId, - visitorId: session.visitorId, - }; }, }); diff --git a/packages/backend/convex/functions/public/index.ts b/packages/backend/convex/functions/public/index.ts index 89329f7..14dc97c 100644 --- a/packages/backend/convex/functions/public/index.ts +++ b/packages/backend/convex/functions/public/index.ts @@ -6,6 +6,7 @@ */ export { getBotProfile } from "./getBotProfile.js"; +export { validateEmbedToken } from "./validateEmbedToken.js"; export { createSession } from "./createSession.js"; export { sendMessage } from "./sendMessage.js"; export { getMessages } from "./getMessages.js"; diff --git a/packages/backend/convex/functions/public/sendMessage.ts b/packages/backend/convex/functions/public/sendMessage.ts index da057eb..a5b26f2 100644 --- a/packages/backend/convex/functions/public/sendMessage.ts +++ b/packages/backend/convex/functions/public/sendMessage.ts @@ -2,21 +2,7 @@ import { v } from "convex/values"; import { mutation } from "../../_generated/server.js"; import { api } from "../../_generated/api.js"; import type { Id } from "../../_generated/dataModel.js"; - -// Helper function to format visitor name consistently -function formatAnonymousVisitorName(visitorId?: string): string { - if (!visitorId) { - return "anonymousid_unknown"; - } - // FNV-1a 32-bit hash - let hash = 0x811c9dc5; - for (let i = 0; i < visitorId.length; i += 1) { - hash ^= visitorId.charCodeAt(i); - hash = (hash * 0x01000193) >>> 0; - } - const hex = hash.toString(16).padStart(8, "0"); - return `anonymousid_${hex.slice(0, 8)}`; -} +import { requireValidVisitorSession, logAudit } from "../../lib/security.js"; /** * PUBLIC MUTATION: Send a message in a public chat session @@ -29,99 +15,66 @@ function formatAnonymousVisitorName(visitorId?: string): string { * * Used by: Public widget during chat interaction * Access: public (no auth required) - * Parameters: sessionId, organizationId, botId, visitorId (implicit), content + * Parameters: conversationId, sessionToken, content * Returns: Message ID or { success: true, messageId } */ export const sendMessage = mutation({ args: { - sessionId: v.string(), // v.id("publicSessions") - but client doesn't know Convex types - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + conversationId: v.string(), + sessionToken: v.string(), content: v.string(), }, handler: async ( ctx, args, ): Promise<{ success: true; messageId: Id<"messages"> }> => { - const session: { - _id: Id<"publicSessions">; - conversationId: Id<"conversations">; - organizationId: string; - botId: string; - visitorId: string; - } | null = await ctx.runQuery(api.public.getSessionDetails, { - sessionId: args.sessionId, - organizationId: args.organizationId, - botId: args.botId, - visitorId: args.visitorId, - }); - - if (!session) { - throw new Error( - "Session not found or does not match provided organization/bot/visitor", - ); - } - - // ✅ VALIDATION 2: Verify conversation still exists - const conversation = await ctx.db.get(session.conversationId); - if (!conversation) { + const conversationId = ctx.db.normalizeId( + "conversations", + args.conversationId, + ); + if (!conversationId) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "public_send_message", + resource_type: "conversation", + resource_id: args.conversationId, + status: "error", + error_message: "Conversation not found", + }); throw new Error("Conversation not found"); } - // ✅ VALIDATION 3: Verify conversation is not closed - if (conversation.status === "closed") { - throw new Error("Conversation is closed"); - } - - // ✅ AUTO-CREATE/UPDATE USER for visitor - // Check if user already exists for this visitor in this organization - const existingUser = await ctx.db - .query("users") - .withIndex("by_org_and_identifier", (q) => - q - .eq("organization_id", args.organizationId) - .eq("identifier", args.visitorId), - ) - .first(); + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); - const now = Date.now(); - const userName = formatAnonymousVisitorName(args.visitorId); + const userMessageId: Id<"messages"> = await ctx.runMutation( + api.monitor.addMessage, + { + conversation_id: conversationId, + role: "user", + content: args.content, + sessionToken: args.sessionToken, + }, + ); - if (existingUser) { - // Update last_active_at - await ctx.db.patch(existingUser._id, { - last_active_at: now, - }); - } else { - // Create new user record - await ctx.db.insert("users", { - organization_id: args.organizationId, - identifier: args.visitorId, - name: userName, - created_at: now, - last_active_at: now, - }); - } - - // ✅ SAVE: User message - const userMessageId = await ctx.db.insert("messages", { - conversation_id: session.conversationId, - visitor_id: args.visitorId, - role: "user", - content: args.content, - created_at: Date.now(), - user_id: undefined, // Public visitor, no user_id + await logAudit(ctx, { + user_id: `visitor:${session.visitor_id}`, + organization_id: (session as any).organization_id as string | undefined, + action: "public_send_message", + resource_type: "message", + resource_id: String(userMessageId), + status: "success", + changes: { + before: null, + after: { + conversationId: String(conversationId), + messageId: String(userMessageId), + }, + }, }); - // ✅ TODO: Delegate to AI response handler (phase 4+) - // This will: - // - Fetch bot config - // - Get conversation history - // - Call AI provider - // - Save bot response - // - Log metrics - return { success: true, messageId: userMessageId, diff --git a/packages/backend/convex/functions/public/trackEvent.ts b/packages/backend/convex/functions/public/trackEvent.ts index a7b84d1..de90e38 100644 --- a/packages/backend/convex/functions/public/trackEvent.ts +++ b/packages/backend/convex/functions/public/trackEvent.ts @@ -1,6 +1,10 @@ import { v } from "convex/values"; import { mutation } from "../../_generated/server.js"; -import { api } from "../../_generated/api.js"; +import { + assertConversationOwnedByVisitorSession, + requireValidVisitorSession, + logAudit, +} from "../../lib/security.js"; import { Doc, Id } from "../../_generated/dataModel.js"; /** @@ -10,10 +14,8 @@ import { Doc, Id } from "../../_generated/dataModel.js"; */ export const trackEvent = mutation({ args: { - sessionId: v.string(), - organizationId: v.string(), - botId: v.string(), - visitorId: v.string(), + conversationId: v.string(), + sessionToken: v.string(), eventType: v.union( v.literal("lead_whatsapp_click"), v.literal("lead_email_click"), @@ -21,43 +23,84 @@ export const trackEvent = mutation({ href: v.string(), }, handler: async (ctx, args) => { - const { sessionId, organizationId, botId, visitorId, eventType, href } = - args; - - const session: { - _id: string; - conversationId: string; - organizationId: string; - botId: string; - visitorId: string; - } | null = await ctx.runQuery(api.public.getSessionDetails, { - sessionId, - organizationId, - botId, - visitorId, - }); + const { + conversationId: conversationIdString, + sessionToken, + eventType, + href, + } = args; - if (!session) { - throw new Error( - "Session validation failed - session not found or invalid", - ); + const conversationId = ctx.db.normalizeId( + "conversations", + conversationIdString, + ); + if (!conversationId) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "track_business_event", + resource_type: "conversation", + resource_id: conversationIdString, + status: "error", + error_message: "Conversation not found", + }); + throw new Error("Conversation not found"); } const conversation: Doc<"conversations"> | null = await ctx.db.get( - session.conversationId as Id<"conversations">, + conversationId as Id<"conversations">, ); if (!conversation) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "track_business_event", + resource_type: "conversation", + resource_id: String(conversationId), + status: "error", + error_message: "Conversation not found", + }); throw new Error("Conversation not found"); } - if (String(conversation.bot_id) !== botId) { - throw new Error("Conversation does not belong to this bot"); - } + const session = await requireValidVisitorSession(ctx, { + sessionToken, + now: Date.now(), + }); + + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); + + const auditUserId = `visitor:${session.visitor_id}`; if (conversation.integration !== "embed") { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: conversation.organization_id, + action: "track_business_event", + resource_type: "conversation", + resource_id: String(conversation._id), + status: "success", + changes: { + before: null, + after: { skipped: true, reason: "not_embed" }, + }, + }); return { success: true, skipped: true, reason: "not_embed" }; } + if (!conversation.organization_id) { + await logAudit(ctx, { + user_id: auditUserId, + action: "track_business_event", + resource_type: "conversation", + resource_id: String(conversation._id), + status: "error", + error_message: "Organization not found", + }); + throw new Error("Organization not found"); + } + const now = Date.now(); const dayKey = new Date(now).toISOString().slice(0, 10); // UTC YYYY-MM-DD const dedupeKey = `${conversation._id}:${eventType}:${dayKey}`; @@ -68,20 +111,45 @@ export const trackEvent = mutation({ .first(); if (existing) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: conversation.organization_id, + action: "track_business_event", + resource_type: "businessEvent", + resource_id: String(existing._id), + status: "success", + changes: { + before: { dedupeKey }, + after: { deduped: true }, + }, + }); return { success: true, deduped: true }; } - await ctx.db.insert("businessEvents", { - organizationId: conversation.organization_id || session.organizationId, + const eventId = await ctx.db.insert("businessEvents", { + organizationId: conversation.organization_id, botId: conversation.bot_id, conversationId: conversation._id, - visitorId: conversation.visitor_id || session.visitorId, + visitorId: session.visitor_id, eventType, href, createdAt: now, dedupeKey, }); + await logAudit(ctx, { + user_id: auditUserId, + organization_id: conversation.organization_id, + action: "track_business_event", + resource_type: "businessEvent", + resource_id: String(eventId), + status: "success", + changes: { + before: null, + after: { eventType, href }, + }, + }); + return { success: true, deduped: false }; }, }); diff --git a/packages/backend/convex/kbanalytics.ts b/packages/backend/convex/kbanalytics.ts index af7d1ea..4225928 100644 --- a/packages/backend/convex/kbanalytics.ts +++ b/packages/backend/convex/kbanalytics.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, query } from "./_generated/server.js"; +import { logAudit } from "./lib/security.js"; export const logKBUsage = internalMutation({ args: { @@ -12,18 +13,55 @@ export const logKBUsage = internalMutation({ handler: async (ctx, args) => { if (args.retrievedDocumentIds.length === 0) return; + const auditUserId = args.user_id ?? "visitor"; + const now = Date.now(); - for (let i = 0; i < args.retrievedDocumentIds.length; i += 1) { - const docId = args.retrievedDocumentIds[i]; - if (!docId) continue; - await ctx.db.insert("kb_usage_logs", { - user_id: args.user_id, - botId: args.botId, - conversationId: args.conversationId, - documentId: docId, - similarity: args.querySimilarities[i] ?? 0, - timestamp: now, + let inserted = 0; + let auditLogged = false; + + try { + for (let i = 0; i < args.retrievedDocumentIds.length; i += 1) { + const docId = args.retrievedDocumentIds[i]; + if (!docId) continue; + await ctx.db.insert("kb_usage_logs", { + user_id: args.user_id, + botId: args.botId, + conversationId: args.conversationId, + documentId: docId, + similarity: args.querySimilarities[i] ?? 0, + timestamp: now, + }); + inserted += 1; + } + + await logAudit(ctx, { + user_id: auditUserId, + action: "log_kb_usage", + resource_type: "kb_usage_logs", + status: "success", + changes: { + before: null, + after: { + botId: args.botId, + conversationId: args.conversationId, + inserted, + }, + }, }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + action: "log_kb_usage", + resource_type: "kb_usage_logs", + status: "error", + error_message: errorMessage, + }); + } + throw error; } }, }); @@ -62,6 +100,29 @@ export const getKBStats = query({ ) .collect(); + // Query-level coverage (successful retrieval vs fallback/no-context) + // We derive this from aiLogs, which are logged for every AI call and + // contain knowledgeChunksRetrieved (0 when RAG returned nothing). + const aiLogs = await ctx.db + .query("aiLogs") + .withIndex("by_botId_createdAt", (q) => + q.eq("botId", args.botId).gte("createdAt", startTime), + ) + .collect(); + + const totalQueries = aiLogs.length; + const successfulRetrievalQueries = aiLogs.filter( + (log) => log.knowledgeChunksRetrieved > 0, + ).length; + const fallbackNoContextQueries = totalQueries - successfulRetrievalQueries; + const retrievalCoveragePercent = + totalQueries > 0 + ? Math.min( + 100, + Math.round((successfulRetrievalQueries / totalQueries) * 100), + ) + : 0; + const usageMap = new Map(); for (const log of usageLogs) { const key = String(log.documentId); @@ -81,23 +142,44 @@ export const getKBStats = query({ .sort((a, b) => b.count - a.count) .slice(0, 5); + const documentUsage = documents + .map((doc) => { + const key = String(doc._id); + const usage = usageMap.get(key); + return { + documentId: key, + count: usage?.count ?? 0, + lastUsedAt: usage?.lastUsedAt ?? 0, + }; + }) + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return b.lastUsedAt - a.lastUsedAt; + }); + const usedDocumentIds = new Set(usageMap.keys()); const unusedDocumentIds = documents .filter((doc) => !usedDocumentIds.has(String(doc._id))) .map((doc) => String(doc._id)); const documentsUsedLastPeriod = usedDocumentIds.size; - const hitRate = - documents.length > 0 - ? Math.round((documentsUsedLastPeriod / documents.length) * 100) - : 0; + + // Legacy field kept for backwards compatibility. + // Previously this could appear >100% due to deleted documents still + // present in kb_usage_logs. UI should prefer retrievalCoveragePercent. + const hitRate = retrievalCoveragePercent; return { totalDocuments: documents.length, documentsUsedLastPeriod, totalRetrievals: usageLogs.length, hitRate, + totalQueries, + successfulRetrievalQueries, + fallbackNoContextQueries, + retrievalCoveragePercent, topDocuments, + documentUsage, unusedDocumentIds, windowDays: args.days ?? 7, }; diff --git a/packages/backend/convex/knowledge.ts b/packages/backend/convex/knowledge.ts index 74d928a..f930a0a 100644 --- a/packages/backend/convex/knowledge.ts +++ b/packages/backend/convex/knowledge.ts @@ -15,8 +15,11 @@ import { checkRobotsTxt, scrapeWebsite, validateWebsiteUrl, + crawlWebsitePages, + type PageMetadata, } from "./websitescraper.js"; import { calculateOptimalChunkSize, chunkDocument } from "./documentchunker.js"; +import { logAudit } from "./lib/security.js"; type SourceMetadata = { filename?: string; @@ -141,12 +144,48 @@ export const insertKnowledge = internalMutation({ embedding: v.array(v.float64()), }, handler: async (ctx, args): Promise> => { - return await ctx.db.insert("documents", { - user_id: args.user_id, - botId: args.botId, - text: args.text, - embedding: args.embedding, - }); + const bot = await ctx.db.get(args.botId); + let auditLogged = false; + try { + const id = await ctx.db.insert("documents", { + user_id: args.user_id, + botId: args.botId, + text: args.text, + embedding: args.embedding, + }); + + await logAudit(ctx, { + user_id: args.user_id, + organization_id: bot?.organization_id, + action: "insert_document", + resource_type: "document", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { + botId: args.botId, + chars: args.text.length, + }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: args.user_id, + organization_id: bot?.organization_id, + action: "insert_document", + resource_type: "document", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -160,14 +199,51 @@ export const insertKnowledgeWithMetadata = internalMutation({ source_metadata: sourceMetadataValidator, }, handler: async (ctx, args): Promise> => { - return await ctx.db.insert("documents", { - user_id: args.user_id, - botId: args.botId, - text: args.text, - embedding: args.embedding, - source_type: args.source_type, - source_metadata: args.source_metadata, - }); + const bot = await ctx.db.get(args.botId); + let auditLogged = false; + try { + const id = await ctx.db.insert("documents", { + user_id: args.user_id, + botId: args.botId, + text: args.text, + embedding: args.embedding, + source_type: args.source_type, + source_metadata: args.source_metadata, + }); + + await logAudit(ctx, { + user_id: args.user_id, + organization_id: bot?.organization_id, + action: "insert_document", + resource_type: "document", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { + botId: args.botId, + source_type: args.source_type, + chars: args.text.length, + }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: args.user_id, + organization_id: bot?.organization_id, + action: "insert_document", + resource_type: "document", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -538,7 +614,49 @@ export const deleteDocumentInternal = internalMutation({ documentId: v.id("documents"), }, handler: async (ctx, args) => { - await ctx.db.delete(args.documentId); + const before = await ctx.db.get(args.documentId); + const bot = before ? await ctx.db.get(before.botId) : null; + const auditUserId = before?.user_id ?? "system"; + + let auditLogged = false; + try { + await ctx.db.delete(args.documentId); + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: bot?.organization_id, + action: "delete_document", + resource_type: "document", + resource_id: String(args.documentId), + status: "success", + changes: { + before: before + ? { + _id: before._id, + botId: before.botId, + source_type: before.source_type, + } + : null, + after: null, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: bot?.organization_id, + action: "delete_document", + resource_type: "document", + resource_id: String(args.documentId), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -605,9 +723,236 @@ export const updateDocumentInternal = internalMutation({ embedding: v.array(v.float64()), }, handler: async (ctx, args) => { - await ctx.db.patch(args.documentId, { - text: args.text, - embedding: args.embedding, - }); + const before = await ctx.db.get(args.documentId); + const bot = before ? await ctx.db.get(before.botId) : null; + const auditUserId = before?.user_id ?? "system"; + + let auditLogged = false; + try { + await ctx.db.patch(args.documentId, { + text: args.text, + embedding: args.embedding, + }); + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: bot?.organization_id, + action: "update_document", + resource_type: "document", + resource_id: String(args.documentId), + status: "success", + changes: { + before: before + ? { + _id: before._id, + botId: before.botId, + source_type: before.source_type, + chars: before.text?.length ?? 0, + } + : null, + after: { + _id: args.documentId, + chars: args.text.length, + }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: bot?.organization_id, + action: "update_document", + resource_type: "document", + resource_id: String(args.documentId), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } + }, +}); + +/** + * Crawl a website to discover all pages and return metadata + * Used in the first step of the multi-page KB ingestion flow + */ +export const crawlWebsiteMeta = action({ + args: { + url: v.string(), + maxPages: v.optional(v.number()), + maxDepth: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Unauthorized: Must be logged in"); + } + + const urlValidation = validateWebsiteUrl(args.url); + if (!urlValidation.valid) { + throw new Error(urlValidation.error ?? "Invalid URL"); + } + + const robotsAllowed = await checkRobotsTxt(args.url); + if (!robotsAllowed) { + throw new Error( + "Website robots.txt disallows scraping. Please contact the site owner.", + ); + } + + const crawlResult = await crawlWebsitePages( + args.url, + args.maxPages ?? 100, + args.maxDepth ?? 3, + ); + + if (crawlResult.error) { + throw new Error(crawlResult.error); + } + + return crawlResult; + }, +}); + +/** + * Scrape multiple website pages and add them as knowledge + * Used in the final step of the multi-page KB ingestion flow + */ +export const scrapeMultipleWebsitesAndAddKnowledge = action({ + args: { + botId: v.id("botProfiles"), + urls: v.array(v.string()), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Unauthorized: Must be logged in"); + } + + if (args.urls.length === 0) { + throw new Error("No URLs provided"); + } + + if (args.urls.length > 50) { + throw new Error("Maximum 50 URLs allowed per batch"); + } + + const allAddedIds: Array> = []; + const errors: Array<{ url: string; error: string }> = []; + const processingTimestamp = Date.now(); + + // Process URLs concurrently with rate limiting (max 3 concurrent) + const batchSize = 3; + for (let i = 0; i < args.urls.length; i += batchSize) { + const batch = args.urls.slice(i, i + batchSize); + + const results = await Promise.all( + batch.map(async (url) => { + try { + // Validate URL + const urlValidation = validateWebsiteUrl(url); + if (!urlValidation.valid) { + return { + success: false, + url, + error: urlValidation.error ?? "Invalid URL", + ids: [] as Array>, + }; + } + + // Scrape the page + const scrapeResult = await scrapeWebsite(url, 15000); + + if (scrapeResult.metadata.content_size === 0) { + return { + success: false, + url, + error: "Website contains no extractable content", + ids: [] as Array>, + }; + } + + // Chunk and add to knowledge + const optimalChunkSize = calculateOptimalChunkSize( + scrapeResult.text, + ); + const chunks = chunkDocument(scrapeResult.text, optimalChunkSize); + const addedIds: Array> = []; + + for (const chunk of chunks) { + const metadata: SourceMetadata = { + url, + domain: scrapeResult.metadata.domain, + scrape_timestamp: processingTimestamp, + is_dynamic_content: scrapeResult.metadata.is_dynamic_content, + original_size_chars: chunk.original_size, + chunk_index: chunk.chunk_index, + chunk_total: chunk.chunk_total, + processing_timestamp: processingTimestamp, + }; + + const embedding: number[] = await ctx.runAction( + internal.knowledge.generateEmbedding, + { + botId: args.botId, + text: chunk.text, + }, + ); + + const id = await ctx.runMutation( + internal.knowledge.insertKnowledgeWithMetadata, + { + user_id: identity.subject, + botId: args.botId, + text: chunk.text, + embedding, + source_type: "website", + source_metadata: metadata, + }, + ); + + addedIds.push(id); + } + + return { + success: true, + url, + ids: addedIds, + error: null, + }; + } catch (error) { + return { + success: false, + url, + error: + error instanceof Error + ? error.message + : "Unknown error occurred", + ids: [] as Array>, + }; + } + }), + ); + + // Collect results + for (const result of results) { + if (result.success) { + allAddedIds.push(...result.ids); + } else if (result.error) { + errors.push({ url: result.url, error: result.error }); + } + } + } + + return { + added_document_ids: allAddedIds, + total_documents_added: allAddedIds.length, + errors, + success: allAddedIds.length > 0, + }; }, }); diff --git a/packages/backend/convex/migrations.ts b/packages/backend/convex/migrations.ts index d8307bb..0a48255 100644 --- a/packages/backend/convex/migrations.ts +++ b/packages/backend/convex/migrations.ts @@ -1,4 +1,5 @@ import { mutation } from "./_generated/server.js"; +import { logAudit } from "./lib/security.js"; /** * MIGRATION SUITE FOR COMPLETE MULTI-TENANCY ISOLATION @@ -44,6 +45,17 @@ export const migrateUserIdForBotProfiles = mutation({ } } + await logAudit(ctx, { + user_id: "system:migration", + action: "migrate_user_id_bot_profiles", + resource_type: "migration", + status: "success", + changes: { + before: null, + after: { deletedCount, skippedCount, errorCount }, + }, + }); + return { success: true, message: `Bot profiles: Deleted ${deletedCount}, Skipped ${skippedCount}, Errors ${errorCount}`, @@ -113,6 +125,22 @@ export const migrateUserIdForConversationsAndMessages = mutation({ } } + await logAudit(ctx, { + user_id: "system:migration", + action: "migrate_user_id_conversations_messages", + resource_type: "migration", + status: "success", + changes: { + before: null, + after: { + conversationsUpdated, + conversationsDeleted, + messagesUpdated, + errorCount, + }, + }, + }); + return { success: true, message: `Conversations: Updated ${conversationsUpdated}, Deleted ${conversationsDeleted} | Messages: Updated ${messagesUpdated} | Errors ${errorCount}`, @@ -167,6 +195,17 @@ export const migrateUserIdForDocuments = mutation({ } } + await logAudit(ctx, { + user_id: "system:migration", + action: "migrate_user_id_documents", + resource_type: "migration", + status: "success", + changes: { + before: null, + after: { updatedCount, deletedCount, errorCount }, + }, + }); + return { success: true, message: `Documents: Updated ${updatedCount}, Deleted ${deletedCount}, Errors ${errorCount}`, @@ -222,6 +261,17 @@ export const migrateDocumentsSourceMetadata = mutation({ } } + await logAudit(ctx, { + user_id: "system:migration", + action: "migrate_documents_source_metadata", + resource_type: "migration", + status: "success", + changes: { + before: null, + after: { updatedCount, skippedCount, errorCount }, + }, + }); + return { success: true, message: `Documents: Updated ${updatedCount}, Skipped ${skippedCount}, Errors ${errorCount}`, @@ -275,6 +325,17 @@ export const migrateUserIdForAiLogs = mutation({ } } + await logAudit(ctx, { + user_id: "system:migration", + action: "migrate_user_id_ai_logs", + resource_type: "migration", + status: "success", + changes: { + before: null, + after: { updatedCount, deletedCount, errorCount }, + }, + }); + return { success: true, message: `AI Logs: Updated ${updatedCount}, Deleted ${deletedCount}, Errors ${errorCount}`, @@ -355,6 +416,17 @@ export const addVisitorSupport = mutation({ `Total records updated: ${conversationsUpdated + messagesUpdated}\n`, ); + await logAudit(ctx, { + user_id: "system:migration", + action: "add_visitor_support", + resource_type: "migration", + status: "success", + changes: { + before: null, + after: { conversationsUpdated, messagesUpdated }, + }, + }); + return { success: true, conversationsUpdated, @@ -363,6 +435,17 @@ export const addVisitorSupport = mutation({ }; } catch (error) { console.error("❌ Migration failed:", error); + + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: "system:migration", + action: "add_visitor_support", + resource_type: "migration", + status: "error", + error_message: errorMessage, + }); + return { success: false, error: String(error), diff --git a/packages/backend/convex/monitor.ts b/packages/backend/convex/monitor.ts index 97ac25c..ab30409 100644 --- a/packages/backend/convex/monitor.ts +++ b/packages/backend/convex/monitor.ts @@ -1,8 +1,22 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server.js"; +import { + assertCanAccessResource, + assertConversationOwnedByVisitorSession, + assertRateLimitMessagesPerWindow, + createVisitorSession as createVisitorSessionHelper, + getTenantContext, + logAudit, + requireBotProfile, + requireValidVisitorSession, +} from "./lib/security.js"; const VISITOR_HASH_LENGTH = 8; +const VISITOR_MESSAGE_RATE_LIMIT_PER_MINUTE = 10; +const VISITOR_MESSAGE_RATE_WINDOW_MS = 60 * 1000; +const VISITOR_MESSAGE_MAX_CHARS = 4000; + function hashVisitorIdToHex8(value: string): string { // FNV-1a 32-bit hash to avoid Node "crypto" dependency let hash = 0x811c9dc5; @@ -21,6 +35,28 @@ function formatAnonymousVisitorName(visitorId?: string): string { return `anonymousid_${hash}`; } +function normalizeVisitorId(input: string): string { + const visitorId = input.trim(); + if (!visitorId) { + throw new Error("visitorId is required"); + } + if (visitorId.length > 200) { + throw new Error("visitorId too long"); + } + return visitorId; +} + +function normalizeMessageContent(input: string): string { + const content = input.trim(); + if (!content) { + throw new Error("Message content is required"); + } + if (content.length > VISITOR_MESSAGE_MAX_CHARS) { + throw new Error("Message too long"); + } + return content; +} + // ===== CONVERSATIONS ===== /** @@ -35,20 +71,15 @@ export const getConversations = query({ status: v.optional(v.string()), }, handler: async (ctx, args) => { - // ✅ Get authenticated user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: Must be logged in"); - } + const tenant = await getTenantContext(ctx); - const userId = identity.subject; + const bot = await ctx.db.get(args.botId); + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); - // ✅ Filter conversations by user_id and botId + // ✅ Filter conversations by botId (authorization is via bot access) const allConversations = await ctx.db .query("conversations") - .withIndex("by_user_bot", (q) => - q.eq("user_id", userId).eq("bot_id", args.botId), - ) + .withIndex("by_bot_id", (q) => q.eq("bot_id", args.botId)) .collect(); // Filter by status if provided @@ -67,9 +98,10 @@ export const getConversations = query({ filtered.map(async (conv) => { const convMessages = await ctx.db .query("messages") - .withIndex("by_user_id", (q) => q.eq("user_id", userId)) - .collect() - .then((msgs) => msgs.filter((m) => m.conversation_id === conv._id)); + .withIndex("by_conversation", (q) => + q.eq("conversation_id", conv._id), + ) + .collect(); const lastMessage = convMessages[convMessages.length - 1] || null; const participant = conv.participant_id @@ -100,15 +132,16 @@ export const getAdminConversations = query({ status: v.optional(v.string()), }, handler: async (ctx, args) => { - // ✅ Get authenticated user (required for admin conversations) - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: Must be logged in"); - } + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); - const userId = identity.subject; + const userId = tenant.userId; // ✅ Filter conversations by user_id and botId + // NOTE: Visitor conversations are also owned by the bot owner (user_id set) + // for monitoring isolation, so we must explicitly exclude visitor_id here. const allConversations = await ctx.db .query("conversations") .withIndex("by_user_bot", (q) => @@ -116,8 +149,11 @@ export const getAdminConversations = query({ ) .collect(); + // Only admin testing conversations (no visitor_id) + const adminOnly = allConversations.filter((c) => !c.visitor_id); + // Filter by status if provided - let filtered = allConversations; + let filtered = adminOnly; if (args.status) { filtered = filtered.filter((c) => c.status === args.status); } @@ -166,28 +202,22 @@ export const getPublicConversations = query({ status: v.optional(v.string()), }, handler: async (ctx, args) => { - // ✅ Get authenticated user (required to view visitor conversations) - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: Must be logged in"); - } - - const userId = identity.subject; + const tenant = await getTenantContext(ctx); - // ✅ Verify user owns this bot const bot = await ctx.db.get(args.botId); - if (!bot || bot.user_id !== userId) { - throw new Error( - "Unauthorized: Cannot view visitor conversations for other user's bots", - ); - } + assertCanAccessResource( + bot, + tenant, + "Unauthorized: Cannot view visitor conversations for this bot", + ); // Get all conversations for this bot where visitor_id is set (public visitors) const allConversations = await ctx.db .query("conversations") .withIndex("by_bot_id", (q) => q.eq("bot_id", args.botId)) .collect() - .then((convs) => convs.filter((c) => c.visitor_id && !c.user_id)); // Only visitor conversations + // Visitor conversations may still carry user_id (bot owner) for admin monitoring. + .then((convs) => convs.filter((c) => Boolean(c.visitor_id))); // Filter by status if provided let filtered = allConversations; @@ -228,12 +258,12 @@ export const getPublicConversations = query({ /** * Get all messages for a specific conversation * ✅ Supports both authenticated users AND public visitors - * For public visitors, provide visitorId instead of relying on auth + * For public visitors, provide a sessionToken instead of visitor_id to prevent impersonation */ export const getConversationMessages = query({ args: { conversationId: v.id("conversations"), - visitorId: v.optional(v.string()), + sessionToken: v.optional(v.string()), }, handler: async (ctx, args) => { // Get conversation first to verify access @@ -244,37 +274,43 @@ export const getConversationMessages = query({ // Determine access: either authenticated user OR visitor const identity = await ctx.auth.getUserIdentity(); - const userId = identity?.subject; - // Verify access: must be either the bot owner, conversation owner, or the visitor - if (userId && conversation.user_id === userId) { - // Case 1: Authenticated user viewing their own test conversation + // Verify access: either tenant access OR the visitor + if (identity) { + // Case 1: Authenticated tenant (owner or org member) viewing any conversation on an accessible bot + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(conversation.bot_id); + assertCanAccessResource( + bot, + tenant, + "Unauthorized: Cannot access this conversation", + ); + return await ctx.db .query("messages") .withIndex("by_conversation", (q) => q.eq("conversation_id", args.conversationId), ) .collect(); - } else if (args.visitorId && conversation.visitor_id === args.visitorId) { - // Case 2: Public visitor viewing their own conversation + } else if (args.sessionToken) { + // Case 2: Public visitor viewing their own conversation via session token + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); + + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); + return await ctx.db .query("messages") .withIndex("by_conversation", (q) => q.eq("conversation_id", args.conversationId), ) .collect(); - } else if (userId && conversation.bot_id) { - // Case 3: Authenticated user (admin) viewing any conversation on their bot - const bot = await ctx.db.get(conversation.bot_id); - if (bot && bot.user_id === userId) { - // Admin owns this bot, so they can view all conversations (admin testing + visitor chats) - return await ctx.db - .query("messages") - .withIndex("by_conversation", (q) => - q.eq("conversation_id", args.conversationId), - ) - .collect(); - } } throw new Error("Unauthorized: Cannot access this conversation"); @@ -285,16 +321,15 @@ export const getConversationMessages = query({ * Create a new conversation * ✅ Supports BOTH authenticated users AND public visitors * - Authenticated: Create admin test conversation with user_id - * - Public Visitor: Create visitor conversation with visitor_id (no auth required) + * - Public Visitor: Create visitor conversation using sessionToken (no auth required) */ export const createConversation = mutation({ args: { bot_id: v.id("botProfiles"), - organization_id: v.optional(v.string()), participant_id: v.optional(v.id("users")), integration: v.string(), topic: v.optional(v.string()), - visitor_id: v.optional(v.string()), // For public visitors + sessionToken: v.optional(v.string()), // For public visitors }, handler: async (ctx, args) => { // Attempt to get authenticated user (may be null for public visitors) @@ -312,28 +347,99 @@ export const createConversation = mutation({ last_message_at: Date.now(), }; + let auditUserId: string = userId ?? "unauthenticated"; + let auditOrgId: string | undefined = + (identity?.org_id as string | undefined) || undefined; + if (userId) { - // Authenticated user: verify bot belongs to this user + const tenant = await getTenantContext(ctx); + const bot = await ctx.db.get(args.bot_id); - if (!bot || bot.user_id !== userId) { - throw new Error( - "Unauthorized: Cannot create conversation for other user's bot", - ); - } + assertCanAccessResource( + bot, + tenant, + "Unauthorized: Cannot create conversation for this bot", + ); conversationData.user_id = userId; conversationData.participant_id = args.participant_id; - } else if (args.visitor_id) { - // Public visitor: no authentication required - // Use organization_id and bot_id for lookups instead - conversationData.visitor_id = args.visitor_id; - conversationData.organization_id = args.organization_id; + if (bot.organization_id) { + conversationData.organization_id = bot.organization_id; + } + auditUserId = tenant.userId; + auditOrgId = bot.organization_id ?? tenant.orgId; + } else if (args.sessionToken) { + // Public visitor: no authentication required. Token validation prevents impersonation. + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); + + if (session.bot_id !== args.bot_id) { + throw new Error("Unauthorized: Wrong bot"); + } + + const bot = await requireBotProfile(ctx, args.bot_id); + + conversationData.visitor_id = session.visitor_id; + if (bot.organization_id) { + conversationData.organization_id = bot.organization_id; + } + // Preserve bot owner isolation for monitoring/admin queries. + conversationData.user_id = bot.user_id; + auditUserId = `visitor:${session.visitor_id}`; + auditOrgId = bot.organization_id; } else { // Neither authenticated nor visitor ID provided - throw new Error("Unauthorized: Must be logged in or provide visitor_id"); + await logAudit(ctx, { + user_id: "unauthenticated", + action: "create_conversation", + resource_type: "conversation", + status: "denied", + error_message: + "Unauthorized: Must be logged in or provide sessionToken", + }); + throw new Error( + "Unauthorized: Must be logged in or provide sessionToken", + ); } - return await ctx.db.insert("conversations", conversationData); + let auditLogged = false; + try { + const id = await ctx.db.insert("conversations", conversationData); + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "create_conversation", + resource_type: "conversation", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { + _id: id, + bot_id: args.bot_id, + integration: args.integration, + }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "create_conversation", + resource_type: "conversation", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -344,42 +450,123 @@ export const createConversation = mutation({ export const closeConversation = mutation({ args: { conversationId: v.id("conversations"), - visitor_id: v.optional(v.string()), // For public visitors + sessionToken: v.optional(v.string()), // For public visitors }, handler: async (ctx, args) => { // Get conversation first const conversation = await ctx.db.get(args.conversationId); if (!conversation) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "close_conversation", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "error", + error_message: "Conversation not found", + }); throw new Error("Conversation not found"); } // Attempt to get authenticated user (may be null for public visitors) const identity = await ctx.auth.getUserIdentity(); - const userId = identity?.subject; - // Verify access: must be either bot owner or the visitor - if (userId) { - // Authenticated user: verify they own this conversation - if (conversation.user_id !== userId) { - throw new Error( - "Unauthorized: Cannot close other user's conversations", - ); - } - } else if (args.visitor_id) { - // Public visitor: verify they own this conversation - if (conversation.visitor_id !== args.visitor_id) { - throw new Error( - "Unauthorized: Visitor cannot close other visitor's conversations", + let auditUserId = identity?.subject ?? "unauthenticated"; + let auditOrgId = (conversation as any).organization_id as + | string + | undefined; + + // Verify access: must be either authenticated tenant with bot access or the visitor + if (identity) { + const tenant = await getTenantContext(ctx); + const bot = await ctx.db.get(conversation.bot_id); + try { + assertCanAccessResource( + bot, + tenant, + "Unauthorized: Cannot close conversations for this bot", ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "close_conversation", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "denied", + error_message: errorMessage, + }); + throw error; } + auditUserId = tenant.userId; + auditOrgId = (bot as any)?.organization_id ?? tenant.orgId; + } else if (args.sessionToken) { + // Public visitor: verify they own this conversation via session token + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); + + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); + + auditUserId = `visitor:${session.visitor_id}`; } else { - throw new Error("Unauthorized: Must be logged in or provide visitor_id"); + await logAudit(ctx, { + user_id: "unauthenticated", + action: "close_conversation", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "denied", + error_message: + "Unauthorized: Must be logged in or provide sessionToken", + }); + throw new Error( + "Unauthorized: Must be logged in or provide sessionToken", + ); } - await ctx.db.patch(args.conversationId, { + const before = conversation; + const patch = { status: "closed", updated_at: Date.now(), - }); + }; + + let auditLogged = false; + try { + await ctx.db.patch(args.conversationId, patch); + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "close_conversation", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "success", + changes: { + before, + after: { ...before, ...patch }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "close_conversation", + resource_type: "conversation", + resource_id: String(args.conversationId), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -387,7 +574,7 @@ export const closeConversation = mutation({ * Add a message to a conversation (sendMessage equivalent) * ✅ Supports BOTH authenticated users AND public visitors * - Authenticated: Associate message with user_id - * - Public Visitor: Associate message with visitor_id (no auth required) + * - Public Visitor: Associate message with visitor_id derived from sessionToken (no auth required) * Updates the conversation's last_message_at timestamp */ export const addMessage = mutation({ @@ -396,12 +583,20 @@ export const addMessage = mutation({ participant_id: v.optional(v.id("users")), role: v.string(), content: v.string(), - visitor_id: v.optional(v.string()), // For public visitors + sessionToken: v.optional(v.string()), // For public visitors }, handler: async (ctx, args) => { // Get conversation first to verify it exists const conversation = await ctx.db.get(args.conversation_id); if (!conversation) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "add_message", + resource_type: "conversation", + resource_id: String(args.conversation_id), + status: "error", + error_message: "Conversation not found", + }); throw new Error("Conversation not found"); } @@ -409,6 +604,11 @@ export const addMessage = mutation({ const identity = await ctx.auth.getUserIdentity(); const userId = identity?.subject; + let auditUserId = userId ?? "unauthenticated"; + let auditOrgId = (conversation as any).organization_id as + | string + | undefined; + // Verify access and determine who is sending the message let messageData: any = { conversation_id: args.conversation_id, @@ -419,35 +619,176 @@ export const addMessage = mutation({ }; if (userId) { - // Authenticated user: verify they own this conversation - if (conversation.user_id !== userId) { - throw new Error( - "Unauthorized: Cannot add messages to other user's conversations", + const tenant = await getTenantContext(ctx); + const bot = await ctx.db.get(conversation.bot_id); + try { + assertCanAccessResource( + bot, + tenant, + "Unauthorized: Cannot add messages to conversations for this bot", ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "add_message", + resource_type: "conversation", + resource_id: String(args.conversation_id), + status: "denied", + error_message: errorMessage, + }); + throw error; } messageData.user_id = userId; - } else if (args.visitor_id) { - // Public visitor: verify they own this conversation - if (conversation.visitor_id !== args.visitor_id) { - throw new Error( - "Unauthorized: Visitor cannot add messages to other visitor's conversations", - ); - } - messageData.visitor_id = args.visitor_id; + auditUserId = tenant.userId; + auditOrgId = (bot as any)?.organization_id ?? tenant.orgId; + } else if (args.sessionToken) { + // Public visitor: verify they own this conversation via session token + const now = Date.now(); + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now, + }); + + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); + + await assertRateLimitMessagesPerWindow(ctx, { + conversationId: args.conversation_id, + limit: VISITOR_MESSAGE_RATE_LIMIT_PER_MINUTE, + windowMs: VISITOR_MESSAGE_RATE_WINDOW_MS, + now, + errorMessage: "Rate limited: Too many messages", + }); + + messageData.visitor_id = session.visitor_id; + messageData.role = "user"; + messageData.content = normalizeMessageContent(args.content); + messageData.created_at = now; + + auditUserId = `visitor:${session.visitor_id}`; } else { - throw new Error("Unauthorized: Must be logged in or provide visitor_id"); + await logAudit(ctx, { + user_id: "unauthenticated", + action: "add_message", + resource_type: "conversation", + resource_id: String(args.conversation_id), + status: "denied", + error_message: + "Unauthorized: Must be logged in or provide sessionToken", + }); + throw new Error( + "Unauthorized: Must be logged in or provide sessionToken", + ); } - // Insert message - const messageId = await ctx.db.insert("messages", messageData); + let auditLogged = false; + try { + // Insert message + const messageId = await ctx.db.insert("messages", messageData); + + // Update conversation's last_message_at + await ctx.db.patch(args.conversation_id, { + last_message_at: Date.now(), + updated_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "add_message", + resource_type: "message", + resource_id: String(messageId), + status: "success", + changes: { + before: null, + after: { + conversation_id: args.conversation_id, + role: messageData.role, + }, + }, + }); + auditLogged = true; + + return messageId; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "add_message", + resource_type: "message", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } + }, +}); - // Update conversation's last_message_at - await ctx.db.patch(args.conversation_id, { - last_message_at: Date.now(), - updated_at: Date.now(), - }); +/** + * Gap #1: Visitor Session Validation + * PUBLIC MUTATION: Create a visitor session token scoped to (visitor_id, bot_id) + * Used by public widget / embed flows before calling any public mutations. + */ +export const createVisitorSession = mutation({ + args: { + botId: v.id("botProfiles"), + visitorId: v.string(), + }, + handler: async (ctx, args) => { + const visitorId = normalizeVisitorId(args.visitorId); + + await requireBotProfile(ctx, args.botId); + + let auditLogged = false; + try { + const { sessionToken, expiresAt } = await createVisitorSessionHelper( + ctx, + { + botId: args.botId, + visitorId, + }, + ); - return messageId; + await logAudit(ctx, { + user_id: `visitor:${visitorId}`, + action: "create_visitor_session", + resource_type: "visitorSession", + status: "success", + changes: { + before: null, + after: { + botId: args.botId, + visitorId, + expiresAt, + }, + }, + }); + auditLogged = true; + + return { sessionToken, expiresAt }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: `visitor:${visitorId}`, + action: "create_visitor_session", + resource_type: "visitorSession", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -462,41 +803,95 @@ export const updateFeedback = mutation({ message_id: v.id("messages"), conversation_id: v.id("conversations"), feedback: v.string(), // "helpful" or "not-helpful" - visitor_id: v.optional(v.string()), // For public visitors + sessionToken: v.optional(v.string()), // For public visitors }, handler: async (ctx, args) => { // Get message and conversation to verify they exist const message = await ctx.db.get(args.message_id); if (!message) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_feedback", + resource_type: "message", + resource_id: String(args.message_id), + status: "error", + error_message: "Message not found", + }); throw new Error("Message not found"); } const conversation = await ctx.db.get(args.conversation_id); if (!conversation) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_feedback", + resource_type: "conversation", + resource_id: String(args.conversation_id), + status: "error", + error_message: "Conversation not found", + }); throw new Error("Conversation not found"); } // Attempt to get authenticated user (may be null for public visitors) const identity = await ctx.auth.getUserIdentity(); - const userId = identity?.subject; - // Verify access: must be either bot owner or the visitor - if (userId) { - // Authenticated user: verify they own this conversation - if (conversation.user_id !== userId) { - throw new Error( - "Unauthorized: Cannot update feedback for other user's conversations", - ); - } - } else if (args.visitor_id) { - // Public visitor: verify they own this conversation - if (conversation.visitor_id !== args.visitor_id) { - throw new Error( - "Unauthorized: Visitor cannot update feedback in other visitor's conversations", + let auditUserId = identity?.subject ?? "unauthenticated"; + const auditOrgId = (conversation as any).organization_id as + | string + | undefined; + + // Verify access: must be either authenticated tenant with bot access or the visitor + if (identity) { + const tenant = await getTenantContext(ctx); + const bot = await ctx.db.get(conversation.bot_id); + try { + assertCanAccessResource( + bot, + tenant, + "Unauthorized: Cannot update feedback for conversations on this bot", ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "update_feedback", + resource_type: "message", + resource_id: String(args.message_id), + status: "denied", + error_message: errorMessage, + }); + throw error; } + auditUserId = tenant.userId; + } else if (args.sessionToken) { + // Public visitor: verify they own this conversation via session token + const session = await requireValidVisitorSession(ctx, { + sessionToken: args.sessionToken, + now: Date.now(), + }); + + await assertConversationOwnedByVisitorSession(ctx, { + conversation, + session, + }); + + auditUserId = `visitor:${session.visitor_id}`; } else { - throw new Error("Unauthorized: Must be logged in or provide visitor_id"); + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_feedback", + resource_type: "message", + resource_id: String(args.message_id), + status: "denied", + error_message: + "Unauthorized: Must be logged in or provide sessionToken", + }); + throw new Error( + "Unauthorized: Must be logged in or provide sessionToken", + ); } // Update message with feedback (store in message if schema supports it) @@ -504,6 +899,16 @@ export const updateFeedback = mutation({ // this can be extended to create a separate feedback table // For now, we just verify the feedback is valid if (args.feedback !== "helpful" && args.feedback !== "not-helpful") { + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "update_feedback", + resource_type: "message", + resource_id: String(args.message_id), + status: "error", + error_message: + "Invalid feedback value: must be 'helpful' or 'not-helpful'", + }); throw new Error( "Invalid feedback value: must be 'helpful' or 'not-helpful'", ); @@ -512,6 +917,19 @@ export const updateFeedback = mutation({ // In a real implementation, patch the message with feedback metadata // await ctx.db.patch(args.message_id, { feedback: args.feedback }); + await logAudit(ctx, { + user_id: auditUserId, + organization_id: auditOrgId, + action: "update_feedback", + resource_type: "message", + resource_id: String(args.message_id), + status: "success", + changes: { + before: { _id: args.message_id }, + after: { _id: args.message_id, feedback: args.feedback }, + }, + }); + return { success: true, messageId: args.message_id }; }, }); @@ -573,6 +991,13 @@ export const getOrCreateUser = mutation({ (identity?.org_id as string | undefined) || undefined; if (!organizationId) { + await logAudit(ctx, { + user_id: identity?.subject ?? "unauthenticated", + action: "get_or_create_user", + resource_type: "user", + status: "denied", + error_message: "Organization context required", + }); throw new Error("Organization context required"); } @@ -589,13 +1014,46 @@ export const getOrCreateUser = mutation({ if (existing) return existing; // Create new user with organization_id - return await ctx.db.insert("users", { - organization_id: organizationId, - identifier: args.identifier, - name: args.name || "Unknown User", - created_at: Date.now(), - last_active_at: Date.now(), - }); + let auditLogged = false; + try { + const id = await ctx.db.insert("users", { + organization_id: organizationId, + identifier: args.identifier, + name: args.name || "Unknown User", + created_at: Date.now(), + last_active_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: identity?.subject ?? "unauthenticated", + organization_id: organizationId, + action: "create_user", + resource_type: "user", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, identifier: args.identifier }, + }, + }); + auditLogged = true; + + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: identity?.subject ?? "unauthenticated", + organization_id: organizationId, + action: "create_user", + resource_type: "user", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -607,8 +1065,112 @@ export const updateUserActivity = mutation({ userId: v.id("users"), }, handler: async (ctx, args) => { - await ctx.db.patch(args.userId, { - last_active_at: Date.now(), - }); + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); + throw new Error("Unauthorized: Must be logged in"); + } + + if (!identity.subject) { + throw new Error("Unauthorized: Missing user identity"); + } + + const organizationId = (identity.org_id as string | undefined) || undefined; + if (!organizationId) { + await logAudit(ctx, { + user_id: identity.subject, + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "denied", + error_message: "Organization context required", + }); + throw new Error("Organization context required"); + } + + const user = await ctx.db.get(args.userId); + if (!user) { + await logAudit(ctx, { + user_id: identity.subject, + organization_id: organizationId, + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "error", + error_message: "User not found", + }); + throw new Error("User not found"); + } + + if (user.organization_id !== organizationId) { + await logAudit(ctx, { + user_id: identity.subject, + organization_id: organizationId, + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "denied", + error_message: "Unauthorized: Cannot update other organization's user", + }); + throw new Error("Unauthorized: Cannot update other organization's user"); + } + + // Prevent updating arbitrary users within the same organization. + // Only the authenticated user can update their own activity record. + if (user.identifier !== identity.subject) { + await logAudit(ctx, { + user_id: identity.subject, + organization_id: organizationId, + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "denied", + error_message: "Unauthorized: Cannot update another user's activity", + }); + throw new Error("Unauthorized: Cannot update another user's activity"); + } + + const before = user; + const patch = { last_active_at: Date.now() }; + + let auditLogged = false; + try { + await ctx.db.patch(args.userId, patch); + await logAudit(ctx, { + user_id: identity.subject, + organization_id: organizationId, + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "success", + changes: { + before, + after: { ...before, ...patch }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: identity.subject, + organization_id: organizationId, + action: "update_user_activity", + resource_type: "user", + resource_id: String(args.userId), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); diff --git a/packages/backend/convex/playground.ts b/packages/backend/convex/playground.ts index d5b69c1..d96fc18 100644 --- a/packages/backend/convex/playground.ts +++ b/packages/backend/convex/playground.ts @@ -2,6 +2,12 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server.js"; import { api } from "./_generated/api.js"; import { Id } from "./_generated/dataModel.js"; +import { + assertCanAccessResource, + assertIsOwner, + getTenantContext, + logAudit, +} from "./lib/security.js"; /** * Create or retrieve the active playground session for a bot @@ -12,37 +18,101 @@ export const getOrCreatePlaygroundSession = mutation({ botId: v.id("botProfiles"), }, handler: async (ctx, args) => { - // Authenticate the user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; - - // Query all conversations for this bot with playground tag and this user - const allConversations = await ctx.db.query("conversations").collect(); - const playgroundSessions = allConversations.filter( - (c) => - c.bot_id === args.botId && - c.user_id === userId && - c.integration === "playground" && - c.status === "active", + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + try { + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "get_or_create_playground_session", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const userId = tenant.userId; + + // Query conversations for this bot with playground tag and this user + const sessions = await ctx.db + .query("conversations") + .withIndex("by_user_bot", (q) => + q.eq("user_id", userId).eq("bot_id", args.botId), + ) + .collect(); + + const playgroundSessions = sessions.filter( + (c) => c.integration === "playground" && c.status === "active", ); // If active playground session exists, return it if (playgroundSessions.length > 0) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "get_or_create_playground_session", + resource_type: "conversation", + resource_id: String(playgroundSessions[0]!._id), + status: "success", + changes: { + before: { _id: playgroundSessions[0]!._id }, + after: { _id: playgroundSessions[0]!._id }, + }, + }); return playgroundSessions[0]; } // Create new playground session with authenticated user - return await ctx.db.insert("conversations", { - bot_id: args.botId, - user_id: userId, - integration: "playground", - topic: "Playground Test Session", - status: "active", - created_at: Date.now(), - updated_at: Date.now(), - last_message_at: Date.now(), - }); + let auditLogged = false; + try { + const id = await ctx.db.insert("conversations", { + bot_id: args.botId, + user_id: userId, + organization_id: bot.organization_id, + integration: "playground", + topic: "Playground Test Session", + status: "active", + created_at: Date.now(), + updated_at: Date.now(), + last_message_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "create_playground_session", + resource_type: "conversation", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, botId: args.botId }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "create_playground_session", + resource_type: "conversation", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -54,10 +124,12 @@ export const getPlaygroundSession = query({ botId: v.id("botProfiles"), }, handler: async (ctx, args) => { - // ✅ Add auth check - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + + const userId = tenant.userId; // ✅ Use indexed query instead of collecting all conversations const sessions = await ctx.db @@ -101,19 +173,38 @@ export const addPlaygroundMessage = mutation({ content: v.string(), }, handler: async (ctx, args) => { - // Authenticate the user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + try { + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "add_playground_message", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const userId = tenant.userId; // Get or create playground session - const allConversations = await ctx.db.query("conversations").collect(); - let playgroundSession = allConversations.find( - (c) => - c.bot_id === args.botId && - c.user_id === userId && - c.integration === "playground" && - c.status === "active", + const sessions = await ctx.db + .query("conversations") + .withIndex("by_user_bot", (q) => + q.eq("user_id", userId).eq("bot_id", args.botId), + ) + .collect(); + + let playgroundSession = sessions.find( + (c) => c.integration === "playground" && c.status === "active", ); if (!playgroundSession) { @@ -121,6 +212,7 @@ export const addPlaygroundMessage = mutation({ const conversationId = await ctx.db.insert("conversations", { bot_id: args.botId, user_id: userId, + organization_id: bot.organization_id, integration: "playground", topic: "Playground Test Session", status: "active", @@ -134,6 +226,7 @@ export const addPlaygroundMessage = mutation({ _creationTime: Date.now(), bot_id: args.botId, user_id: userId, + organization_id: bot.organization_id, integration: "playground", topic: "Playground Test Session", status: "active", @@ -143,22 +236,52 @@ export const addPlaygroundMessage = mutation({ }; } - // Add message to conversation - const messageId = await ctx.db.insert("messages", { - conversation_id: playgroundSession._id, - user_id: userId, - role: args.role, - content: args.content, - created_at: Date.now(), - }); - - // Update conversation's last_message_at - await ctx.db.patch(playgroundSession._id, { - last_message_at: Date.now(), - updated_at: Date.now(), - }); - - return messageId; + let auditLogged = false; + try { + // Add message to conversation + const messageId = await ctx.db.insert("messages", { + conversation_id: playgroundSession._id, + user_id: userId, + role: args.role, + content: args.content, + created_at: Date.now(), + }); + + // Update conversation's last_message_at + await ctx.db.patch(playgroundSession._id, { + last_message_at: Date.now(), + updated_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "add_playground_message", + resource_type: "message", + resource_id: String(messageId), + status: "success", + changes: { + before: null, + after: { conversationId: playgroundSession._id, role: args.role }, + }, + }); + auditLogged = true; + return messageId; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "add_playground_message", + resource_type: "message", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -171,19 +294,38 @@ export const restartPlaygroundSession = mutation({ botId: v.id("botProfiles"), }, handler: async (ctx, args) => { - // Authenticate the user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + try { + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "restart_playground_session", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const userId = tenant.userId; // Find and close the current active playground session - const allConversations = await ctx.db.query("conversations").collect(); - const playgroundSession = allConversations.find( - (c) => - c.bot_id === args.botId && - c.user_id === userId && - c.integration === "playground" && - c.status === "active", + const sessions = await ctx.db + .query("conversations") + .withIndex("by_user_bot", (q) => + q.eq("user_id", userId).eq("bot_id", args.botId), + ) + .collect(); + + const playgroundSession = sessions.find( + (c) => c.integration === "playground" && c.status === "active", ); if (playgroundSession) { @@ -193,17 +335,50 @@ export const restartPlaygroundSession = mutation({ }); } - // Create a fresh playground session with authenticated user - return await ctx.db.insert("conversations", { - bot_id: args.botId, - user_id: userId, - integration: "playground", - topic: "Playground Test Session", - status: "active", - created_at: Date.now(), - updated_at: Date.now(), - last_message_at: Date.now(), - }); + let auditLogged = false; + try { + // Create a fresh playground session with authenticated user + const id = await ctx.db.insert("conversations", { + bot_id: args.botId, + user_id: userId, + organization_id: bot.organization_id, + integration: "playground", + topic: "Playground Test Session", + status: "active", + created_at: Date.now(), + updated_at: Date.now(), + last_message_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "restart_playground_session", + resource_type: "conversation", + resource_id: String(id), + status: "success", + changes: { + before: playgroundSession ? { _id: playgroundSession._id } : null, + after: { _id: id }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "restart_playground_session", + resource_type: "conversation", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -215,16 +390,15 @@ export const getPlaygroundMessages = query({ sessionId: v.id("conversations"), }, handler: async (ctx, args) => { - // ✅ Add auth check - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); // ✅ Verify conversation ownership before returning messages const conversation = await ctx.db.get(args.sessionId); - if (!conversation || conversation.user_id !== userId) { - throw new Error("Unauthorized: Cannot access this conversation"); - } + assertIsOwner( + conversation, + tenant, + "Unauthorized: Cannot access this conversation", + ); // ✅ Use indexed query instead of collecting all messages return await ctx.db @@ -249,37 +423,100 @@ export const getOrCreateEmulatorSession = mutation({ botId: v.id("botProfiles"), }, handler: async (ctx, args) => { - // Authenticate the user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; - - // Query all conversations for this bot with emulator tag and this user - const allConversations = await ctx.db.query("conversations").collect(); - const emulatorSessions = allConversations.filter( - (c) => - c.bot_id === args.botId && - c.user_id === userId && - c.integration === "emulator" && - c.status === "active", + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + try { + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "get_or_create_emulator_session", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const userId = tenant.userId; + + const sessions = await ctx.db + .query("conversations") + .withIndex("by_user_bot", (q) => + q.eq("user_id", userId).eq("bot_id", args.botId), + ) + .collect(); + + const emulatorSessions = sessions.filter( + (c) => c.integration === "emulator" && c.status === "active", ); // If active emulator session exists, return it if (emulatorSessions.length > 0) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "get_or_create_emulator_session", + resource_type: "conversation", + resource_id: String(emulatorSessions[0]!._id), + status: "success", + changes: { + before: { _id: emulatorSessions[0]!._id }, + after: { _id: emulatorSessions[0]!._id }, + }, + }); return emulatorSessions[0]; } - // Create new emulator session with authenticated user - return await ctx.db.insert("conversations", { - bot_id: args.botId, - user_id: userId, - integration: "emulator", - topic: "Emulator Test Session", - status: "active", - created_at: Date.now(), - updated_at: Date.now(), - last_message_at: Date.now(), - }); + let auditLogged = false; + try { + // Create new emulator session with authenticated user + const id = await ctx.db.insert("conversations", { + bot_id: args.botId, + user_id: userId, + organization_id: bot.organization_id, + integration: "emulator", + topic: "Emulator Test Session", + status: "active", + created_at: Date.now(), + updated_at: Date.now(), + last_message_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "create_emulator_session", + resource_type: "conversation", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, botId: args.botId }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "create_emulator_session", + resource_type: "conversation", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -291,10 +528,17 @@ export const getEmulatorSession = query({ botId: v.id("botProfiles"), }, handler: async (ctx, args): Promise => { - // ✅ Use indexed query instead of collecting all conversations + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + + // ✅ Use indexed query scoped to user + bot const sessions = await ctx.db .query("conversations") - .withIndex("by_bot_id", (q) => q.eq("bot_id", args.botId)) + .withIndex("by_user_bot", (q) => + q.eq("user_id", tenant.userId).eq("bot_id", args.botId), + ) .collect(); const emulatorSession = sessions.find( @@ -331,19 +575,38 @@ export const addEmulatorMessage = mutation({ content: v.string(), }, handler: async (ctx, args) => { - // Authenticate the user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + try { + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "add_emulator_message", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const userId = tenant.userId; // Get or create emulator session - const allConversations = await ctx.db.query("conversations").collect(); - let emulatorSession = allConversations.find( - (c) => - c.bot_id === args.botId && - c.user_id === userId && - c.integration === "emulator" && - c.status === "active", + const sessions = await ctx.db + .query("conversations") + .withIndex("by_user_bot", (q) => + q.eq("user_id", userId).eq("bot_id", args.botId), + ) + .collect(); + + let emulatorSession = sessions.find( + (c) => c.integration === "emulator" && c.status === "active", ); if (!emulatorSession) { @@ -351,6 +614,7 @@ export const addEmulatorMessage = mutation({ const conversationId = await ctx.db.insert("conversations", { bot_id: args.botId, user_id: userId, + organization_id: bot.organization_id, integration: "emulator", topic: "Emulator Test Session", status: "active", @@ -364,6 +628,7 @@ export const addEmulatorMessage = mutation({ _creationTime: Date.now(), bot_id: args.botId, user_id: userId, + organization_id: bot.organization_id, integration: "emulator", topic: "Emulator Test Session", status: "active", @@ -373,22 +638,52 @@ export const addEmulatorMessage = mutation({ }; } - // Add message to conversation - const messageId = await ctx.db.insert("messages", { - conversation_id: emulatorSession._id, - user_id: userId, - role: args.role, - content: args.content, - created_at: Date.now(), - }); - - // Update conversation's last_message_at - await ctx.db.patch(emulatorSession._id, { - last_message_at: Date.now(), - updated_at: Date.now(), - }); - - return messageId; + let auditLogged = false; + try { + // Add message to conversation + const messageId = await ctx.db.insert("messages", { + conversation_id: emulatorSession._id, + user_id: userId, + role: args.role, + content: args.content, + created_at: Date.now(), + }); + + // Update conversation's last_message_at + await ctx.db.patch(emulatorSession._id, { + last_message_at: Date.now(), + updated_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "add_emulator_message", + resource_type: "message", + resource_id: String(messageId), + status: "success", + changes: { + before: null, + after: { conversationId: emulatorSession._id, role: args.role }, + }, + }); + auditLogged = true; + return messageId; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "add_emulator_message", + resource_type: "message", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -401,19 +696,38 @@ export const restartEmulatorSession = mutation({ botId: v.id("botProfiles"), }, handler: async (ctx, args) => { - // Authenticate the user - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); + + const bot = await ctx.db.get(args.botId); + try { + assertCanAccessResource(bot, tenant, "Unauthorized: Cannot access bot"); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: tenant.orgId, + action: "restart_emulator_session", + resource_type: "botProfile", + resource_id: String(args.botId), + status: "denied", + error_message: errorMessage, + }); + throw error; + } + + const userId = tenant.userId; // Find and close the current active emulator session - const allConversations = await ctx.db.query("conversations").collect(); - const emulatorSession = allConversations.find( - (c) => - c.bot_id === args.botId && - c.user_id === userId && - c.integration === "emulator" && - c.status === "active", + const sessions = await ctx.db + .query("conversations") + .withIndex("by_user_bot", (q) => + q.eq("user_id", userId).eq("bot_id", args.botId), + ) + .collect(); + + const emulatorSession = sessions.find( + (c) => c.integration === "emulator" && c.status === "active", ); if (emulatorSession) { @@ -423,17 +737,50 @@ export const restartEmulatorSession = mutation({ }); } - // Create a fresh emulator session with authenticated user - return await ctx.db.insert("conversations", { - bot_id: args.botId, - user_id: userId, - integration: "emulator", - topic: "Emulator Test Session", - status: "active", - created_at: Date.now(), - updated_at: Date.now(), - last_message_at: Date.now(), - }); + let auditLogged = false; + try { + // Create a fresh emulator session with authenticated user + const id = await ctx.db.insert("conversations", { + bot_id: args.botId, + user_id: userId, + organization_id: bot.organization_id, + integration: "emulator", + topic: "Emulator Test Session", + status: "active", + created_at: Date.now(), + updated_at: Date.now(), + last_message_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "restart_emulator_session", + resource_type: "conversation", + resource_id: String(id), + status: "success", + changes: { + before: emulatorSession ? { _id: emulatorSession._id } : null, + after: { _id: id }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: tenant.userId, + organization_id: bot.organization_id ?? tenant.orgId, + action: "restart_emulator_session", + resource_type: "conversation", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -445,16 +792,15 @@ export const getEmulatorMessages = query({ sessionId: v.id("conversations"), }, handler: async (ctx, args) => { - // ✅ Add auth check - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Unauthorized"); - const userId = identity.subject; + const tenant = await getTenantContext(ctx); // ✅ Verify conversation ownership before returning messages const conversation = await ctx.db.get(args.sessionId); - if (!conversation || conversation.user_id !== userId) { - throw new Error("Unauthorized: Cannot access this conversation"); - } + assertIsOwner( + conversation, + tenant, + "Unauthorized: Cannot access this conversation", + ); // ✅ Use indexed query instead of collecting all messages return await ctx.db diff --git a/packages/backend/convex/public.ts b/packages/backend/convex/public.ts index 1487673..ff9085c 100644 --- a/packages/backend/convex/public.ts +++ b/packages/backend/convex/public.ts @@ -7,6 +7,7 @@ export { getBotProfile, + validateEmbedToken, createSession, sendMessage, getMessages, diff --git a/packages/backend/convex/testing.ts b/packages/backend/convex/testing.ts index b8436e4..21734db 100644 --- a/packages/backend/convex/testing.ts +++ b/packages/backend/convex/testing.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server.js"; import type { Doc, Id, TableNames } from "./_generated/dataModel.js"; +import { logAudit } from "./lib/security.js"; type EscalationConfig = { enabled: boolean; @@ -46,6 +47,8 @@ export const resetTestData = mutation({ handler: async (ctx, args) => { assertTestingEnabled(); + const auditOrgId = args.organizationId; + const matchOrg = (value?: string) => !args.organizationId || value === args.organizationId; @@ -120,7 +123,7 @@ export const resetTestData = mutation({ await deleteByIds(ctx, documentIds); await deleteByIds(ctx, botIds); - return { + const result = { deleted: { botProfiles: botIds.length, conversations: conversationIds.length, @@ -132,6 +135,20 @@ export const resetTestData = mutation({ businessEvents: businessEventIds.length, }, }; + + await logAudit(ctx, { + user_id: "system:test", + organization_id: auditOrgId, + action: "reset_test_data", + resource_type: "testing", + status: "success", + changes: { + before: null, + after: result.deleted, + }, + }); + + return result; }, }); @@ -225,7 +242,20 @@ export const insertBotProfile = mutation({ record.embed_token_domain = args.embed_token_domain; } - return await ctx.db.insert("botProfiles", record); + const id = await ctx.db.insert("botProfiles", record); + await logAudit(ctx, { + user_id: args.user_id ?? "system:test", + organization_id: args.organization_id, + action: "insert_bot_profile_test", + resource_type: "botProfile", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id }, + }, + }); + return id; }, }); @@ -274,7 +304,20 @@ export const insertConversation = mutation({ record.visitor_id = args.visitor_id; } - return await ctx.db.insert("conversations", record); + const id = await ctx.db.insert("conversations", record); + await logAudit(ctx, { + user_id: args.user_id ?? "system:test", + organization_id: args.organization_id, + action: "insert_conversation_test", + resource_type: "conversation", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, bot_id: args.bot_id }, + }, + }); + return id; }, }); @@ -308,7 +351,19 @@ export const insertMessage = mutation({ record.participant_id = args.participant_id; } - return await ctx.db.insert("messages", record); + const id = await ctx.db.insert("messages", record); + await logAudit(ctx, { + user_id: args.user_id ?? "system:test", + action: "insert_message_test", + resource_type: "message", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, conversation_id: args.conversation_id }, + }, + }); + return id; }, }); @@ -382,7 +437,20 @@ export const insertDocument = mutation({ record.source_metadata = sourceMetadata; } - return await ctx.db.insert("documents", record); + const id = await ctx.db.insert("documents", record); + await logAudit(ctx, { + user_id: userId ?? "system:test", + organization_id: (await ctx.db.get(args.botId))?.organization_id, + action: "insert_document_test", + resource_type: "document", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, botId: args.botId }, + }, + }); + return id; }, }); @@ -412,7 +480,20 @@ export const insertPublicSession = mutation({ record.endedAt = args.endedAt; } - return await ctx.db.insert("publicSessions", record); + const id = await ctx.db.insert("publicSessions", record); + await logAudit(ctx, { + user_id: `visitor:${args.visitorId}`, + organization_id: args.organizationId, + action: "insert_public_session_test", + resource_type: "publicSession", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id, conversationId: args.conversationId }, + }, + }); + return id; }, }); diff --git a/packages/backend/convex/webchat.ts b/packages/backend/convex/webchat.ts index a5b15fb..4efb4ab 100644 --- a/packages/backend/convex/webchat.ts +++ b/packages/backend/convex/webchat.ts @@ -1,5 +1,7 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server.js"; +import type { Id } from "./_generated/dataModel.js"; +import { logAudit } from "./lib/security.js"; // ===== BOT PROFILES ===== @@ -21,7 +23,15 @@ export const getBotProfile = query({ .collect(); if (profilesWithUserId.length > 0) { - return profilesWithUserId[0]; + const profile = profilesWithUserId[0]; + if (profile) { + const hasApiKey = Boolean(profile.api_key); + return { + ...profile, + api_key: null, + has_api_key: hasApiKey, + }; + } } // ⚠️ Fallback: If no profile exists, return null (ensureBotProfile will create one) @@ -31,10 +41,17 @@ export const getBotProfile = query({ // Create initial profile if doesn't exist export const ensureBotProfile = mutation({ - handler: async (ctx) => { + handler: async (ctx): Promise> => { // ✅ Get authenticated user's identity const identity = await ctx.auth.getUserIdentity(); if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "ensure_bot_profile", + resource_type: "botProfile", + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); throw new Error("Unauthorized: Must be logged in"); } @@ -47,33 +64,91 @@ export const ensureBotProfile = mutation({ .withIndex("by_user_id", (q) => q.eq("user_id", userId)) .collect(); - if (existing.length > 0) return existing[0]; - - return await ctx.db.insert("botProfiles", { - user_id: userId, - organization_id: organizationId, - avatar_url: "", - bot_names: "My Bot", - bot_description: "", - msg_placeholder: "Type your message...", - primary_color: "#3276EA", - font: "inter", - theme_mode: "light", - header_style: "basic", - message_style: "filled", - corner_radius: 16, - enable_feedback: false, - enable_file_upload: false, - enable_sound: false, - history_reset: "never", - escalation: { - enabled: false, - whatsapp: "", - email: "", - }, - created_at: Date.now(), - updated_at: Date.now(), - }); + if (existing.length > 0) { + const profile = existing[0]; + if (!profile) { + await logAudit(ctx, { + user_id: userId, + organization_id: organizationId, + action: "ensure_bot_profile", + resource_type: "botProfile", + status: "error", + error_message: "Invariant: existing bot profile missing", + }); + throw new Error("Invariant: existing bot profile missing"); + } + await logAudit(ctx, { + user_id: userId, + organization_id: organizationId, + action: "ensure_bot_profile", + resource_type: "botProfile", + resource_id: String(profile._id), + status: "success", + changes: { + before: { _id: profile._id }, + after: { _id: profile._id }, + }, + }); + return profile._id; + } + + let auditLogged = false; + try { + const id = await ctx.db.insert("botProfiles", { + user_id: userId, + organization_id: organizationId, + avatar_url: "", + bot_names: "My Bot", + bot_description: "", + msg_placeholder: "Type your message...", + primary_color: "#3276EA", + font: "inter", + theme_mode: "light", + header_style: "basic", + message_style: "filled", + corner_radius: 16, + enable_feedback: false, + enable_file_upload: false, + enable_sound: false, + history_reset: "never", + escalation: { + enabled: false, + whatsapp: "", + email: "", + }, + created_at: Date.now(), + updated_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: userId, + organization_id: organizationId, + action: "create_bot_profile", + resource_type: "botProfile", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id }, + }, + }); + auditLogged = true; + return id; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: userId, + organization_id: organizationId, + action: "create_bot_profile", + resource_type: "botProfile", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -100,6 +175,14 @@ export const updateBotProfile = mutation({ // ✅ Verify user is authenticated const identity = await ctx.auth.getUserIdentity(); if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "update_bot_profile", + resource_type: "botProfile", + resource_id: String(args.id), + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); throw new Error("Unauthorized: Must be logged in"); } @@ -109,16 +192,68 @@ export const updateBotProfile = mutation({ // ✅ Verify this profile belongs to the authenticated user const profile = await ctx.db.get(id); if (!profile) { + await logAudit(ctx, { + user_id: userId, + organization_id: (identity.org_id as string | undefined) || undefined, + action: "update_bot_profile", + resource_type: "botProfile", + resource_id: String(id), + status: "error", + error_message: "Profile not found", + }); throw new Error("Profile not found"); } if (profile.user_id !== userId) { + await logAudit(ctx, { + user_id: userId, + organization_id: (identity.org_id as string | undefined) || undefined, + action: "update_bot_profile", + resource_type: "botProfile", + resource_id: String(id), + status: "denied", + error_message: "Unauthorized: Cannot update other user's profile", + }); throw new Error("Unauthorized: Cannot update other user's profile"); } - await ctx.db.patch(id, { + const before = profile; + const patch = { ...updates, updated_at: Date.now(), - }); + }; + + let auditLogged = false; + try { + await ctx.db.patch(id, patch); + await logAudit(ctx, { + user_id: userId, + organization_id: profile.organization_id, + action: "update_bot_profile", + resource_type: "botProfile", + resource_id: String(id), + status: "success", + changes: { + before, + after: { ...before, ...patch }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: userId, + organization_id: profile.organization_id, + action: "update_bot_profile", + resource_type: "botProfile", + resource_id: String(id), + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); @@ -134,10 +269,16 @@ export const getBotProfiles = query({ const userId = identity.subject; // ✅ Return only current user's profiles - return await ctx.db + const profiles = await ctx.db .query("botProfiles") .withIndex("by_user_id", (q) => q.eq("user_id", userId)) .collect(); + + return profiles.map((profile) => ({ + ...profile, + api_key: null, + has_api_key: Boolean(profile.api_key), + })); }, }); @@ -153,36 +294,74 @@ export const updateBotProfiles = mutation({ // ✅ Get authenticated user's identity const identity = await ctx.auth.getUserIdentity(); if (!identity) { + await logAudit(ctx, { + user_id: "unauthenticated", + action: "create_bot_profile_legacy", + resource_type: "botProfile", + status: "denied", + error_message: "Unauthorized: Must be logged in", + }); throw new Error("Unauthorized: Must be logged in"); } const userId = identity.subject; const organizationId = (identity.org_id as string | undefined) || undefined; - await ctx.db.insert("botProfiles", { - user_id: userId, - organization_id: organizationId, - avatar_url: args.avatar_url, - bot_names: args.bot_names, - bot_description: args.bot_description, - msg_placeholder: args.msg_placeholder, - primary_color: "#3276EA", - font: "inter", - theme_mode: "light", - header_style: "basic", - message_style: "filled", - corner_radius: 16, - enable_feedback: false, - enable_file_upload: false, - enable_sound: false, - history_reset: "never", - escalation: { - enabled: false, - whatsapp: "", - email: "", - }, - created_at: Date.now(), - updated_at: Date.now(), - }); + let auditLogged = false; + try { + const id = await ctx.db.insert("botProfiles", { + user_id: userId, + organization_id: organizationId, + avatar_url: args.avatar_url, + bot_names: args.bot_names, + bot_description: args.bot_description, + msg_placeholder: args.msg_placeholder, + primary_color: "#3276EA", + font: "inter", + theme_mode: "light", + header_style: "basic", + message_style: "filled", + corner_radius: 16, + enable_feedback: false, + enable_file_upload: false, + enable_sound: false, + history_reset: "never", + escalation: { + enabled: false, + whatsapp: "", + email: "", + }, + created_at: Date.now(), + updated_at: Date.now(), + }); + + await logAudit(ctx, { + user_id: userId, + organization_id: organizationId, + action: "create_bot_profile_legacy", + resource_type: "botProfile", + resource_id: String(id), + status: "success", + changes: { + before: null, + after: { _id: id }, + }, + }); + auditLogged = true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!auditLogged) { + await logAudit(ctx, { + user_id: userId, + organization_id: organizationId, + action: "create_bot_profile_legacy", + resource_type: "botProfile", + status: "error", + error_message: errorMessage, + }); + } + throw error; + } }, }); diff --git a/packages/backend/convex/websitescraper.ts b/packages/backend/convex/websitescraper.ts index 6c103db..c1faacf 100644 --- a/packages/backend/convex/websitescraper.ts +++ b/packages/backend/convex/websitescraper.ts @@ -15,6 +15,20 @@ export interface WebsiteParseResult { const DEFAULT_TIMEOUT_MS = 10000; const DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; Chatify-Bot/1.0)"; +export interface PageMetadata { + url: string; + title?: string; + description?: string; + estimated_size?: number; +} + +export interface CrawlResult { + pages: PageMetadata[]; + total_found: number; + discovery_method: "sitemap" | "crawl"; + error?: string; +} + export async function scrapeWebsite( url: string, timeout: number = DEFAULT_TIMEOUT_MS, @@ -42,9 +56,12 @@ export async function scrapeWebsite( const html = await response.text(); const $ = cheerio.load(html); - $( - "script, style, nav, footer, aside, .ad, .advertisement, .sidebar", - ).remove(); + // Selective cleaning: keep generic containers (div/span/etc) because they + // frequently hold important pricing/spec data. + $("script, style, noscript").remove(); + $("svg, canvas").remove(); + // Remove only clearly ad-like containers (avoid generic selectors). + removeLikelyAdContainers($, $.root()); const title = $("title").text() || $("h1").first().text() || ""; const description = @@ -57,11 +74,14 @@ export async function scrapeWebsite( "main, article, [role='main'], .content, .main-content", ).first(); - if (mainContent.length > 0) { - content = mainContent.text(); - } else { - content = $("body").text(); - } + const contentRoot = ( + mainContent.length > 0 ? mainContent : $("body") + ).first(); + + // DOM-to-text preprocessing (must happen BEFORE calling `.text()`) + preprocessDomForTextExtraction($, contentRoot); + + content = contentRoot.text(); content = content .split("\n") @@ -91,6 +111,117 @@ export async function scrapeWebsite( } } +function preprocessDomForTextExtraction( + $: cheerio.CheerioAPI, + root: cheerio.Cheerio, +): void { + // 1) Preserve input/textarea values + root.find("input").each((_, el) => { + const $el = $(el); + const rawVal = $el.attr("value") ?? ""; + const val = normalizeInlineValue(rawVal); + if (!val) return; + $el.replaceWith(formatInputReplacement(val)); + }); + + root.find("textarea").each((_, el) => { + const $el = $(el); + const rawVal = $el.text(); + const val = normalizeInlineValue(rawVal); + if (!val) return; + $el.replaceWith(formatInputReplacement(val)); + }); + + // 2) Preserve image alt text + root.find("img[alt]").each((_, el) => { + const $el = $(el); + const alt = normalizeInlineValue($el.attr("alt") ?? ""); + if (!alt || !isMeaningfulAlt(alt)) return; + $el.replaceWith(` [Image: ${alt}] `); + }); + + // 3) Improve spacing + // Convert hard breaks to newlines + root.find("br").replaceWith("\n"); + + // Add newlines around block-ish elements so adjacent text doesn't smash + root.find("div, p, li, tr").each((_, el) => { + const $el = $(el); + // before/after avoid disturbing nested structure; cleanup later collapses repeats. + $el.before("\n"); + $el.after("\n"); + }); + + // Add spaces around common inline containers that often cause concatenation + root.find("span, td, th, label").each((_, el) => { + const $el = $(el); + $el.before(" "); + $el.after(" "); + }); + + // 4) Selective cleaning inside root as well + root.find("script, style, noscript, svg, canvas").remove(); + removeLikelyAdContainers($, root); +} + +function normalizeInlineValue(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function looksLikeData(value: string): boolean { + // Heuristics: numbers, account-like strings, emails, long identifiers. + if (/[0-9]{4,}/.test(value)) return true; + if (value.includes("@")) return true; + if (value.length >= 12 && /[A-Za-z0-9]/.test(value)) return true; + // Currency/price patterns + if (/(rp\s?\d)|(\d[\d.,\s]{2,}\d)/i.test(value)) return true; + return false; +} + +function formatInputReplacement(value: string): string { + // If it seems like real data (account number, price, etc), keep just the value. + if (looksLikeData(value)) return ` ${value} `; + return ` [Input: ${value}] `; +} + +function isMeaningfulAlt(alt: string): boolean { + if (alt.length < 3) return false; + if (/^(image|photo|picture|logo|icon)$/i.test(alt)) return false; + return true; +} + +function removeLikelyAdContainers( + $: cheerio.CheerioAPI, + scope: cheerio.Cheerio, +): void { + scope.find("*[class], *[id]").each((_, el) => { + const $el = $(el); + const classAttr = ($el.attr("class") ?? "").toLowerCase(); + const idAttr = ($el.attr("id") ?? "").toLowerCase(); + + // Tokenize to reduce false positives (e.g. "header") + const tokens = `${classAttr} ${idAttr}` + .split(/\s+/) + .map((t) => t.trim()) + .filter(Boolean); + + const isAd = tokens.some((t) => { + // common ad tokens + if (t === "ad" || t === "ads" || t === "advert" || t === "advertisement") + return true; + if (t.startsWith("ad-") || t.endsWith("-ad")) return true; + if (t.startsWith("ads-") || t.endsWith("-ads")) return true; + if (t.includes("adslot") || t.includes("adsense")) return true; + if (t.includes("banner") && t.includes("ad")) return true; + return false; + }); + + if (isAd) { + $el.remove(); + } + }); +} + export function validateWebsiteUrl(url: string): { valid: boolean; error?: string; @@ -205,3 +336,252 @@ function isPrivateHostname(hostname: string): boolean { return false; } + +/** + * Fetch and parse sitemap.xml to discover pages + */ +async function fetchSitemap(baseUrl: string): Promise { + try { + const urlObj = new URL("/sitemap.xml", baseUrl); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(urlObj.toString(), { + method: "GET", + headers: { "User-Agent": DEFAULT_USER_AGENT }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) return []; + + const xml = await response.text(); + const $ = cheerio.load(xml, { xmlMode: true }); + + const urls: PageMetadata[] = []; + $("loc").each((_, elem) => { + const url = $(elem).text(); + if (url && isValidPageUrl(url, baseUrl)) { + urls.push({ url }); + } + }); + + return urls; + } catch { + return []; + } +} + +/** + * Extract page metadata (title, description) from a URL + */ +async function extractPageMetadata(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: "GET", + headers: { "User-Agent": DEFAULT_USER_AGENT }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { url }; + } + + const html = await response.text(); + const $ = cheerio.load(html); + + const title = $("title").text() || $("h1").first().text() || ""; + const descMeta1 = $("meta[name='description']").attr("content"); + const descMeta2 = $("meta[property='og:description']").attr("content"); + const description: string = (descMeta1 ?? "") || (descMeta2 ?? "") || ""; + + return { + url, + title: title || undefined, + description: description || undefined, + estimated_size: html.length, + }; + } catch { + return { url }; + } +} + +/** + * Check if URL is valid for crawling (same domain, not external) + */ +function isValidPageUrl(urlString: string, baseUrl: string): boolean { + try { + const urlObj = new URL(urlString); + const baseObj = new URL(baseUrl); + + // Must be same domain + if (urlObj.hostname !== baseObj.hostname) { + return false; + } + + // Skip anchors, query params (except necessary ones), media files + const pathname = urlObj.pathname.toLowerCase(); + if ( + pathname.endsWith(".pdf") || + pathname.endsWith(".zip") || + pathname.endsWith(".exe") || + pathname.endsWith(".jpg") || + pathname.endsWith(".png") || + pathname.endsWith(".gif") + ) { + return false; + } + + // Skip obvious non-page URLs + if ( + pathname.includes("/cdn-cgi/") || + pathname.includes("/static/") || + pathname.includes("/assets/") + ) { + return false; + } + + return true; + } catch { + return false; + } +} + +/** + * Crawl website using BFS to discover all pages + * Hybrid approach: Uses sitemap URLs as seeds but also crawls from homepage + * to discover pages not listed in sitemap (e.g., static pages in navbar/footer) + * Returns list of discovered pages with metadata + */ +export async function crawlWebsitePages( + startUrl: string, + maxPages: number = 100, + maxDepth: number = 3, +): Promise { + try { + const baseUrl = new URL(startUrl).origin; + const visited = new Set(); + const seen = new Set(); + const discoveredPages: Map = new Map(); + const queue: Array<{ url: string; depth: number }> = []; + let discoveryMethod: "sitemap" | "crawl" = "crawl"; + + const enqueue = (url: string, depth: number) => { + if (seen.has(url)) return; + seen.add(url); + queue.push({ url, depth }); + }; + + // Always crawl starting from homepage to discover internal links + enqueue(startUrl, 0); + + // Step 1: Fetch sitemap and seed discovered pages, but do NOT return early + const sitemapPages = await fetchSitemap(baseUrl); + if (sitemapPages.length > 0) { + discoveryMethod = "sitemap"; + + for (const page of sitemapPages) { + if (!discoveredPages.has(page.url)) { + discoveredPages.set(page.url, { url: page.url }); + } + enqueue(page.url, 0); + } + } + + // Step 2: BFS crawling - discover pages from sitemap AND from homepage/internal links + while (queue.length > 0 && discoveredPages.size < maxPages) { + const { url, depth } = queue.shift()!; + + if (visited.has(url) || depth > maxDepth) { + continue; + } + + visited.add(url); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: "GET", + headers: { "User-Agent": DEFAULT_USER_AGENT }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + continue; + } + + const html = await response.text(); + const $ = cheerio.load(html); + + // Extract page metadata + const title = + $("title").text() || + $("h1").first().text() || + url.split("/").pop() || + ""; + const descMeta1 = $("meta[name='description']").attr("content"); + const descMeta2 = $("meta[property='og:description']").attr("content"); + const description: string = + (descMeta1 ?? "") || (descMeta2 ?? "") || ""; + + discoveredPages.set(url, { + url, + title: title || undefined, + description: description || undefined, + estimated_size: html.length, + }); + + // Extract and queue new links (discover pages missed by sitemap) + $("a[href]").each((_, elem) => { + if (discoveredPages.size >= maxPages) return; + + const hrefRaw = $(elem).attr("href"); + const href: string = typeof hrefRaw === "string" ? hrefRaw : ""; + if (!href) return; + + try { + const linkUrl = new URL(href, url).toString().split("#")[0]; // Remove anchors + + if ( + linkUrl && + isValidPageUrl(linkUrl, baseUrl) && + queue.length < maxPages * 2 + ) { + if (!discoveredPages.has(linkUrl)) { + discoveredPages.set(linkUrl, { url: linkUrl }); + } + enqueue(linkUrl, depth + 1); + } + } catch { + // Invalid URL, skip + } + }); + } catch { + // Fetch failed, continue with next URL + } + } + + return { + pages: Array.from(discoveredPages.values()), + total_found: discoveredPages.size, + discovery_method: discoveryMethod, + }; + } catch (error) { + return { + pages: [], + total_found: 0, + discovery_method: "crawl", + error: error instanceof Error ? error.message : "Failed to crawl website", + }; + } +} diff --git a/packages/ui/src/components/widget/chat/chat-container.tsx b/packages/ui/src/components/widget/chat/chat-container.tsx index 76c6e50..93ac8fe 100644 --- a/packages/ui/src/components/widget/chat/chat-container.tsx +++ b/packages/ui/src/components/widget/chat/chat-container.tsx @@ -54,7 +54,7 @@ export function ChatContainer({ useEffect(() => { setInput(""); setIsRefreshing(false); - }, [session?.id]); + }, [session?.conversationId]); const handleSend = async (content: string) => { if (!content.trim()) return; diff --git a/packages/ui/src/components/widget/chat/chat-messages.tsx b/packages/ui/src/components/widget/chat/chat-messages.tsx index 793d31d..f63e348 100644 --- a/packages/ui/src/components/widget/chat/chat-messages.tsx +++ b/packages/ui/src/components/widget/chat/chat-messages.tsx @@ -116,7 +116,7 @@ export function ChatMessages({ borderTopLeftRadius: `${cornerRadius * 0.15}px`, }} > -

+

{streamingContent}

diff --git a/packages/ui/src/components/widget/chat/message-bubble.tsx b/packages/ui/src/components/widget/chat/message-bubble.tsx index 08c9b20..d0a73b3 100644 --- a/packages/ui/src/components/widget/chat/message-bubble.tsx +++ b/packages/ui/src/components/widget/chat/message-bubble.tsx @@ -12,10 +12,10 @@ import { useState } from "react"; import type { MessageBubbleProps } from "../types.ts"; /** - * MarkdownRenderer - Simple inline markdown support - * For full markdown rendering, should be replaced with react-markdown + * Simple inline markdown support. + * Note: This intentionally does not render raw HTML. */ -function SimpleMarkdown({ content }: { content: string }) { +function renderInlineMarkdown(content: string) { const tokens = content .split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g) .filter(Boolean); @@ -46,6 +46,86 @@ function SimpleMarkdown({ content }: { content: string }) { ); } +type MessageBlock = + | { type: "paragraph"; text: string } + | { type: "list"; items: string[] }; + +const LIST_ITEM_REGEX = /^\s*([-*•])\s+(.+)$/; + +function parseMessageBlocks(content: string): MessageBlock[] { + const normalized = content.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + + const blocks: MessageBlock[] = []; + let paragraphLines: string[] = []; + let listItems: string[] = []; + + const flushParagraph = () => { + const text = paragraphLines.join("\n").trim(); + if (text) blocks.push({ type: "paragraph", text }); + paragraphLines = []; + }; + + const flushList = () => { + if (listItems.length > 0) blocks.push({ type: "list", items: listItems }); + listItems = []; + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + flushList(); + flushParagraph(); + continue; + } + + const listMatch = trimmed.match(LIST_ITEM_REGEX); + if (listMatch) { + flushParagraph(); + listItems.push(listMatch[2] ?? ""); + continue; + } + + flushList(); + paragraphLines.push(line); + } + + flushList(); + flushParagraph(); + return blocks; +} + +function FormattedMessage({ content }: { content: string }) { + const blocks = parseMessageBlocks(content); + + return ( +
+ {blocks.map((block, index) => { + if (block.type === "list") { + return ( +
    + {block.items.map((item, itemIndex) => ( +
  • + {renderInlineMarkdown(item)} +
  • + ))} +
+ ); + } + + return ( +

+ {renderInlineMarkdown(block.text)} +

+ ); + })} +
+ ); +} + /** * MessageBubble - Pure UI component * Renders a single message with optional feedback controls @@ -297,7 +377,7 @@ export function MessageBubble({ } } > - +
diff --git a/packages/ui/src/components/widget/types.ts b/packages/ui/src/components/widget/types.ts index 20115c6..cda2c2a 100644 --- a/packages/ui/src/components/widget/types.ts +++ b/packages/ui/src/components/widget/types.ts @@ -50,10 +50,10 @@ export type LeadClickPayload = { }; export interface ChatSession { - id: string; + sessionToken: string; + conversationId: string; organizationId: string; botId: string; - visitorId: string; createdAt: string; updatedAt: string; }