diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2841b1..f5d3274a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,18 @@ jobs: - name: Type check run: pnpm type-check + # TypeScript's unused-symbol checks do not span modules, so a *closed + # island* — a group of files importing each other with no entry point + # from the app — compiles cleanly and type-checks cleanly forever. One + # (4 messaging modules, 719 LOC) survived from the initial commit to + # 2026-08 and cost two debugging sessions plus a false root cause on #69 + # before anyone noticed. This is the only check that catches that shape. + # + # knip.jsonc carries a baseline of pre-existing findings (see #90) so + # this gate is green on arrival and fails only on NEW dead files. + - name: Detect dead code (unreferenced files) + run: pnpm lint:dead + # scripts/validate-breakpoints.ts asserts that the --breakpoint-* custom # properties in globals.css match BREAKPOINTS in src/config/breakpoints.ts, # and that the ranges have no gaps. It has existed since the breakpoints diff --git a/docs/SECURITY-ARCHITECTURE.md b/docs/SECURITY-ARCHITECTURE.md index 3166c0df..5e17a05e 100644 --- a/docs/SECURITY-ARCHITECTURE.md +++ b/docs/SECURITY-ARCHITECTURE.md @@ -180,22 +180,40 @@ Auth events are logged to `audit_logs` table: ## Performance Security Trade-offs -### ECDH Caching +### ECDH derivation is amortized per batch, not cached -To prevent denial-of-service via expensive cryptographic operations, shared secrets are cached: +There is **no client-side shared-secret cache**. A cached design existed in +`useConversationRealtime` / `decryption-cache`, but it was never wired to any +route and was deleted (#92) — this section previously described it as a live +DoS mitigation, which it never was. -```typescript -// Module-level cache (cleared on page unload) -const sharedSecretCache = new Map(); -``` +The shipping path derives the shared secret **once per `getMessageHistory()` +call** and reuses it across every message in that page: + +- `message-service.ts:801` — one `deriveSharedSecret()` per batch +- `message-service.ts:813-876` — up to 50 AES-GCM decryptions against it + +So the expensive asymmetric operation is already amortized over the page. Cost +scales with the number of _fetches_, not the number of messages. + +**Security properties this gives up, and gains:** -- **Key**: `${conversationId}:${otherParticipantId}` -- **Value**: Derived CryptoKey -- **Invalidation**: Page unload, logout +- **Gives up**: nothing meaningful. Re-derivation is a cost issue, not a + security one, and it is tracked as such in #91. +- **Gains**: no long-lived key material in module scope, and rotation and + revocation self-heal — `getUserPublicKey()` re-applies `.eq('revoked', false)` + (`key-service.ts:691`) on every fetch, so a revoked peer key stops being used + within one cycle with no invalidation machinery to get wrong. -This reduces per-message decryption from ~50ms to ~1ms. +Derived message keys are created **non-extractable** (`encryption.ts:97`), so +they cannot be exported to raw bytes by page script. -**File**: `src/hooks/useConversationRealtime.ts` +If per-message memoization is ever reintroduced, key it on +`(messageId, keyEpoch)` where the epoch derives from **both** parties' current +public keys, with evict-and-retry-once on decrypt failure. A subscription-based +invalidation (the previous design) is structurally blind to _peer_ rotation: +static-static ECDH means K(A,B) changes when either side rotates, and nothing +subscribes to the other party's key changes. ## OWASP Top 10 Compliance diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 00000000..543bdea6 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,86 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + + // Next.js app-router entry points. knip's built-in Next plugin is disabled + // (`"next": { "entry": [] }`) because this project is a static export with a + // non-default layout, and the plugin's assumptions produced false entries. + "entry": [ + "src/app/**/{page,layout,template,loading,error,not-found,global-error,route,default}.{ts,tsx}", + ".storybook/{main,preview}.{ts,tsx}", + "scripts/**/*.{ts,mjs,js}", + "tests/**/*.{ts,tsx}", + "src/**/*.stories.tsx", + "src/**/*.test.{ts,tsx}", + "src/**/*.accessibility.test.tsx", + "public/sw.js" + ], + + "project": ["src/**/*.{ts,tsx}"], + + "ignoreDependencies": ["sharp", "canvas", "argon2"], + "ignoreBinaries": ["docker", "supabase"], + + // ── Baseline (#92) ──────────────────────────────────────────────────────── + // + // knip was added to catch a *closed dead island* — four messaging modules + // that imported each other and nothing else, which survived eight months + // because TypeScript's unused-symbol checks do not span modules. Nothing in + // the repo could detect that mechanically. + // + // These entries are the pre-existing findings at the time knip landed. They + // are ignored so the gate is green on day one and fails on anything NEW. + // This is a burn-down list, not an allowlist — see #90, which is the audit + // of these same orphans. Delete entries from here as they are resolved. + // + // Two distinct categories, deliberately not separated in config because knip + // takes one list: + // 1. One-line barrel files (`index.ts`) re-exporting a LIVE component. + // An import-style decision, not dead code — see #82's out-of-scope note. + // 2. Genuine orphans awaiting individual verification under #90. + "ignore": [ + // Resolved by Vite alias in .storybook/main.ts:43,47 (path.resolve), which + // knip cannot follow — these are reachable, not orphaned. + ".storybook/mocks/**", + + "src/components/AccessibilityProvider.tsx", + "src/components/atomic/Card/index.tsx", + "src/components/atomic/index.ts", + "src/components/atomic/QueueStatusIndicator/useQueueStatusIndicator.ts", + "src/components/atomic/ReadReceipt/index.tsx", + "src/components/forms/FormError.tsx", + "src/components/forms/FormField.tsx", + "src/components/forms/index.ts", + "src/components/forms/ValidatedInput.tsx", + "src/components/molecular/BlogContent/index.tsx", + "src/components/molecular/MessageThread/useMessageThread.ts", + "src/components/organisms/ChatWindow/useChatWindow.ts", + "src/components/organisms/CompanyMap/index.tsx", + "src/components/organisms/ConnectionManager/useConnectionManager.ts", + "src/components/payment/PaymentButton/index.tsx", + "src/components/payment/PaymentConsentModal/index.tsx", + "src/components/payment/PaymentHistory/index.tsx", + "src/components/payment/PaymentStatusDisplay/index.tsx", + "src/components/subatomic/index.ts", + "src/components/subatomic/Text/index.tsx", + "src/config/blog.config.ts", + "src/config/social-platforms.ts", + "src/config/social.ts", + "src/lib/analytics/index.tsx", + "src/lib/auth/protected-route.tsx", + "src/lib/companies/index.ts", + "src/lib/map/index.ts", + "src/lib/seo/content.ts", + "src/lib/seo/keywords.ts", + "src/lib/seo/readability.ts", + "src/lib/seo/technical.ts", + "src/lib/supabase/messaging-types.ts", + "src/lib/supabase/server.ts", + "src/lib/validation/index.ts", + "src/types/**", + "src/utils/codeblock-utils.ts", + "src/utils/map-colors.ts", + "src/utils/test-utils.ts" + ], + + "next": { "entry": [] } +} diff --git a/package.json b/package.json index 8eba9c8a..92862252 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "lint:staged": "lint-staged", "clean:next": "rm -rf .next/* .next/.* 2>/dev/null || true", "type-check": "tsc --noEmit", + "lint:dead": "knip --include files --no-progress", "ci:validate": "./scripts/validate-ci.sh", "validate:breakpoints": "tsx scripts/validate-breakpoints.ts", "ci:quick": "./scripts/validate-ci.sh --quick", @@ -173,6 +174,7 @@ "husky": "^9.1.7", "jest-axe": "^10.0.0", "jsdom": "^26.1.0", + "knip": "^6.31.0", "libsodium-wrappers": "^0.8.1", "lint-staged": "^16.1.6", "msw": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3626f72f..4a5c6bc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,7 +201,7 @@ importers: version: 10.2.8(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/nextjs-vite': specifier: 10.2.8 - version: 10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@tailwindcss/postcss': specifier: ^4.1.13 version: 4.1.13 @@ -243,7 +243,7 @@ importers: version: 19.1.9(@types/react@19.1.13) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.0.15 version: 4.0.15(vitest@4.0.15) @@ -273,16 +273,16 @@ importers: version: 17.2.2 eslint: specifier: ^9.35.0 - version: 9.35.0(jiti@2.5.1) + version: 9.35.0(jiti@2.7.0) eslint-config-next: specifier: 15.5.2 - version: 15.5.2(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + version: 15.5.2(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) eslint-plugin-no-secrets: specifier: ^2.2.1 - version: 2.2.1(eslint@9.35.0(jiti@2.5.1)) + version: 2.2.1(eslint@9.35.0(jiti@2.7.0)) eslint-plugin-storybook: specifier: ^10.2.8 - version: 10.2.8(eslint@9.35.0(jiti@2.5.1))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) + version: 10.2.8(eslint@9.35.0(jiti@2.7.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) fake-indexeddb: specifier: ^6.2.2 version: 6.2.2 @@ -304,6 +304,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0(canvas@3.2.0) + knip: + specifier: ^6.31.0 + version: 6.31.0 libsodium-wrappers: specifier: ^0.8.1 version: 0.8.1 @@ -360,10 +363,10 @@ importers: version: 5.9.2 vite: specifier: '>=7.1.11' - version: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + version: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) vitest: specifier: ^4.0.15 - version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) wait-on: specifier: ^8.0.5 version: 8.0.5 @@ -555,15 +558,24 @@ packages: resolution: {integrity: sha512-DGSlP9sPvyFba3to2A50kDtZ+pXVp/0rhmqs2LmbMS3I5J8FSOgLwzY2Xb4qfKlOVHh29EAutLYwe5yuEZmEFg==} engines: {node: '>=14.0.0'} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} @@ -1080,6 +1092,13 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@next/bundle-analyzer@15.5.3': resolution: {integrity: sha512-l2NxnWHP2gWHbomAlz/wFnN2jNCx/dpr7P/XWeOLhULiyKkXSac8O8SjxRO/8FNhr2l4JNtWVKk82Uya4cZYTw==} @@ -1177,6 +1196,223 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.142.0': + resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.142.0': + resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.142.0': + resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.142.0': + resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.142.0': + resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.142.0': + resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.142.0': + resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@phc/format@1.0.0': resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} @@ -1609,6 +1845,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3042,6 +3281,9 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3117,6 +3359,11 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} @@ -3174,6 +3421,9 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-value@2.0.6: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} @@ -3725,6 +3975,10 @@ packages: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} @@ -3808,6 +4062,11 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + knip@6.31.0: + resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -4434,6 +4693,13 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.142.0: + resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5133,6 +5399,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + sort-asc@0.2.0: resolution: {integrity: sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==} engines: {node: '>=0.10.0'} @@ -5305,6 +5575,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5382,6 +5656,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -5549,6 +5827,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + unbash@4.0.4: + resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==} + engines: {node: '>=14'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -5746,6 +6028,10 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -5908,6 +6194,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5927,6 +6218,9 @@ packages: zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6139,12 +6433,23 @@ snapshots: '@emailjs/browser@4.4.1': {} + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.5.0': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.5.0': dependencies: tslib: 2.8.1 @@ -6155,6 +6460,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@epic-web/invariant@1.0.0': {} '@esbuild/aix-ppc64@0.25.9': @@ -6235,14 +6545,14 @@ snapshots: '@esbuild/win32-x64@0.25.9': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.7.0))': dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.35.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.35.0(jiti@2.7.0))': dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -6473,11 +6783,11 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: glob: 13.0.0 react-docgen-typescript: 2.4.0(typescript@5.9.2) - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) optionalDependencies: typescript: 5.9.2 @@ -6576,6 +6886,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/bundle-analyzer@15.5.3': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -6647,6 +6964,133 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.142.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.142.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.142.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + optional: true + + '@oxc-project/types@0.142.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@phc/format@1.0.0': {} '@playwright/test@1.57.0': @@ -6779,25 +7223,25 @@ snapshots: storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/builder-vite@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/csf-plugin': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/csf-plugin@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.9 rollup: 4.59.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -6806,18 +7250,18 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@storybook/nextjs-vite@10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/nextjs-vite@10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@storybook/react': 10.2.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) - '@storybook/react-vite': 10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/react-vite': 10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) next: 15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.1.0) - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - vite-plugin-storybook-nextjs: 3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + vite-plugin-storybook-nextjs: 3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -6834,11 +7278,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/react-vite@10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@storybook/react': 10.2.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) empathic: 2.0.0 magic-string: 0.30.21 @@ -6848,7 +7292,7 @@ snapshots: resolve: 1.22.10 storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -7042,6 +7486,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -7210,15 +7659,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.43.0 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -7227,14 +7676,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.43.0 debug: 4.4.1(supports-color@5.5.0) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -7275,13 +7724,13 @@ snapshots: dependencies: typescript: 5.9.2 - '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) debug: 4.4.1(supports-color@5.5.0) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -7322,24 +7771,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/utils@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.55.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/utils@8.55.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.35.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.35.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.55.0 '@typescript-eslint/types': 8.55.0 '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -7428,7 +7877,7 @@ snapshots: optionalDependencies: maplibre-gl: 5.15.0 - '@vitejs/plugin-react@5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -7436,7 +7885,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7453,7 +7902,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7474,14 +7923,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.15 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.7.0(@types/node@20.19.14)(typescript@5.9.2) - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -7517,7 +7966,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -8376,19 +8825,19 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.2(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2): + eslint-config-next@15.5.2(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2): dependencies: '@next/eslint-plugin-next': 15.5.2 '@rushstack/eslint-patch': 1.12.0 - '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.35.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0(jiti@2.7.0)) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -8404,33 +8853,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@5.5.0) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.35.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8439,9 +8888,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -8453,13 +8902,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.35.0(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -8469,7 +8918,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -8478,15 +8927,15 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-no-secrets@2.2.1(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-no-secrets@2.2.1(eslint@9.35.0(jiti@2.7.0)): dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) - eslint-plugin-react-hooks@5.2.0(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-react-hooks@5.2.0(eslint@9.35.0(jiti@2.7.0)): dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) - eslint-plugin-react@7.37.5(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-react@7.37.5(eslint@9.35.0(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -8494,7 +8943,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -8508,10 +8957,10 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@10.2.8(eslint@9.35.0(jiti@2.5.1))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2): + eslint-plugin-storybook@10.2.8(eslint@9.35.0(jiti@2.7.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2): dependencies: - '@typescript-eslint/utils': 8.55.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + '@typescript-eslint/utils': 8.55.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.35.0(jiti@2.7.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - supports-color @@ -8526,9 +8975,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.35.0(jiti@2.5.1): + eslint@9.35.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.1 @@ -8564,7 +9013,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.5.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -8688,6 +9137,10 @@ snapshots: dependencies: format: 0.2.2 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -8757,6 +9210,10 @@ snapshots: format@0.2.2: {} + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + from@0.1.7: {} fsevents@2.3.2: @@ -8816,6 +9273,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + get-value@2.0.6: {} github-from-package@0.0.0: {} @@ -9425,6 +9886,8 @@ snapshots: jiti@2.5.1: {} + jiti@2.7.0: {} + joi@17.13.3: dependencies: '@hapi/hoek': 9.3.0 @@ -9533,6 +9996,22 @@ snapshots: kind-of@6.0.3: {} + knip@6.31.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + oxc-parser: 0.142.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.4 + smol-toml: 1.7.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.4 + yaml: 2.9.0 + zod: 4.4.3 + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -10389,6 +10868,53 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.142.0: + dependencies: + '@oxc-project/types': 0.142.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.142.0 + '@oxc-parser/binding-android-arm64': 0.142.0 + '@oxc-parser/binding-darwin-arm64': 0.142.0 + '@oxc-parser/binding-darwin-x64': 0.142.0 + '@oxc-parser/binding-freebsd-x64': 0.142.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 + '@oxc-parser/binding-linux-arm64-musl': 0.142.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-musl': 0.142.0 + '@oxc-parser/binding-openharmony-arm64': 0.142.0 + '@oxc-parser/binding-wasm32-wasi': 0.142.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 + '@oxc-parser/binding-win32-x64-msvc': 0.142.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11178,6 +11704,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smol-toml@1.7.1: {} + sort-asc@0.2.0: {} sort-desc@0.2.0: {} @@ -11392,6 +11920,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.1.0): dependencies: client-only: 0.0.1 @@ -11477,6 +12007,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -11628,6 +12163,8 @@ snapshots: uglify-js@3.19.3: optional: true + unbash@4.0.4: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -11766,7 +12303,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-storybook-nextjs@3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): + vite-plugin-storybook-nextjs@3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 @@ -11775,24 +12312,24 @@ snapshots: next: 15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - vite-tsconfig-paths: 5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + vite-tsconfig-paths: 5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)): dependencies: debug: 4.4.1(supports-color@5.5.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.2) optionalDependencies: - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.4) @@ -11803,16 +12340,16 @@ snapshots: optionalDependencies: '@types/node': 20.19.14 fsevents: 2.3.3 - jiti: 2.5.1 + jiti: 2.7.0 lightningcss: 1.30.1 terser: 5.44.0 tsx: 4.20.6 - yaml: 2.8.1 + yaml: 2.9.0 - vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.15 '@vitest/runner': 4.0.15 '@vitest/snapshot': 4.0.15 @@ -11829,7 +12366,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -11874,6 +12411,8 @@ snapshots: transitivePeerDependencies: - debug + walk-up-path@4.0.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -12037,6 +12576,8 @@ snapshots: yaml@2.8.1: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -12055,4 +12596,6 @@ snapshots: zod@4.1.8: {} + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/specs/010-group-chats/plan.md b/specs/010-group-chats/plan.md index 1e9923ea..15e67498 100644 --- a/specs/010-group-chats/plan.md +++ b/specs/010-group-chats/plan.md @@ -77,7 +77,8 @@ src/ │ └── connection-service.ts # MODIFY: Group creation ├── hooks/ │ ├── useGroupMembers.ts # NEW: Group member management -│ └── useConversationRealtime.ts # MODIFY: Group message handling +│ # (no hook here — the realtime subscription +│ # lives in src/app/messages/page.tsx) ├── types/ │ └── messaging.ts # MODIFY: Add group types ├── lib/ diff --git a/specs/010-group-chats/research.md b/specs/010-group-chats/research.md index 495d7cc1..ab21dab8 100644 --- a/specs/010-group-chats/research.md +++ b/specs/010-group-chats/research.md @@ -154,7 +154,9 @@ All technical context items resolved. No NEEDS CLARIFICATION items identified - - Subscribe to `conversation_members` changes for member join/leave - Subscribe to `messages` for new group messages -- Pattern matches existing `useConversationRealtime` hook +- Pattern matches the realtime subscription in `src/app/messages/page.tsx` + (the former `useConversationRealtime` hook was never wired to a route and was + deleted in #92 — do not build against it) ### RLS Policies for Group Security diff --git a/specs/010-group-chats/tasks.md b/specs/010-group-chats/tasks.md index 9651fa46..56739589 100644 --- a/specs/010-group-chats/tasks.md +++ b/specs/010-group-chats/tasks.md @@ -103,9 +103,9 @@ - [ ] T035 [US2] Implement group message encryption: fetch group key, encrypt with AES-GCM, store with key_version (depends on T034) - [ ] T036 [US2] Modify getMessageHistory() in same file to decrypt group messages using appropriate key version (depends on T035) - [ ] T037 [US2] Implement key_version_joined check: show "[Message before you joined]" placeholder text per SC-003 for pre-join messages (depends on T036) -- [ ] T038 [US2] Update useConversationRealtime hook in `src/hooks/useConversationRealtime.ts` to handle group message decryption +- [ ] T038 [US2] Update the realtime subscription in `src/app/messages/page.tsx` to handle group message decryption (retargeted in #92: the `useConversationRealtime` hook this originally named was never wired to a route and has been deleted) - [ ] T039 [US2] Modify MessageBubble in `src/components/atomic/MessageBubble/MessageBubble.tsx` to show sender avatar for group messages -- [ ] T040 [US2] Modify ChatWindow in `src/components/organisms/ChatWindow/ChatWindow.tsx` to detect group and show multiple typing indicators (up to 3 names, then "X others are typing" per FR-015); update useConversationRealtime to track multiple typers +- [ ] T040 [US2] Modify ChatWindow in `src/components/organisms/ChatWindow/ChatWindow.tsx` to detect group and show multiple typing indicators (up to 3 names, then "X others are typing" per FR-015); update `useTypingIndicator` to track multiple typers **Checkpoint**: User Story 2 complete - encrypted group messaging works, history restriction verified diff --git a/src/hooks/__tests__/useConversationRealtime.test.ts b/src/hooks/__tests__/useConversationRealtime.test.ts deleted file mode 100644 index 822f6a88..00000000 --- a/src/hooks/__tests__/useConversationRealtime.test.ts +++ /dev/null @@ -1,660 +0,0 @@ -/** - * Unit Tests for useConversationRealtime Hook - * Task: T121 - * - * Tests real-time conversation management hook with mocked Supabase client. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, waitFor, act } from '@testing-library/react'; -import { useConversationRealtime } from '../useConversationRealtime'; -import { realtimeService } from '@/lib/messaging/realtime'; -import { messageService } from '@/services/messaging/message-service'; -import { keyManagementService } from '@/services/messaging/key-service'; -import { encryptionService } from '@/lib/messaging/encryption'; -import { createClient } from '@/lib/supabase/client'; - -// Mock dependencies -vi.mock('@/lib/supabase/client'); -vi.mock('@/lib/messaging/realtime'); -vi.mock('@/services/messaging/message-service'); -vi.mock('@/lib/messaging/encryption'); -vi.mock('@/services/messaging/key-service'); - -describe('useConversationRealtime', () => { - const mockConversationId = 'test-conversation-id'; - const mockUserId = 'test-user-id'; - const mockMessages = [ - { - id: 'msg-1', - conversation_id: mockConversationId, - sender_id: mockUserId, - content: 'Test message 1', - sequence_number: 1, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - isOwn: true, - senderName: 'Test User', - }, - { - id: 'msg-2', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - content: 'Test message 2', - sequence_number: 2, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - isOwn: false, - senderName: 'Other User', - }, - ]; - - let mockSupabase: any; - let mockUnsubscribeMessages: ReturnType; - let mockUnsubscribeUpdates: ReturnType; - - beforeEach(() => { - // Mock Supabase client - mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: { id: mockUserId } }, - error: null, - }), - }, - from: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ - data: { - participant_1_id: mockUserId, - participant_2_id: 'other-user-id', - }, - error: null, - }), - }; - - (createClient as any).mockReturnValue(mockSupabase); - - // Mock message service - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: false, - cursor: null, - }); - - (messageService.sendMessage as any).mockResolvedValue({ - success: true, - message: mockMessages[0], - }); - - // Mock realtime service - mockUnsubscribeMessages = vi.fn(); - mockUnsubscribeUpdates = vi.fn(); - - (realtimeService.subscribeToMessages as any).mockReturnValue( - mockUnsubscribeMessages - ); - (realtimeService.subscribeToMessageUpdates as any).mockReturnValue( - mockUnsubscribeUpdates - ); - (realtimeService.unsubscribeFromConversation as any) = vi.fn(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should load messages on mount', async () => { - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - // Initial loading state - expect(result.current.loading).toBe(true); - expect(result.current.messages).toEqual([]); - - // Wait for messages to load - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.messages).toEqual(mockMessages); - expect(result.current.error).toBeNull(); - }); - - it('should subscribe to realtime messages on mount with reconnect handler', async () => { - renderHook(() => useConversationRealtime(mockConversationId)); - - await waitFor(() => { - expect(realtimeService.subscribeToMessages).toHaveBeenCalledWith( - mockConversationId, - expect.any(Function), - expect.any(Function), // onReconnect callback - expect.any(Function) // onSubscribed callback (E2E DOM attribute) - ); - }); - - expect(realtimeService.subscribeToMessageUpdates).toHaveBeenCalledWith( - mockConversationId, - expect.any(Function) - ); - }); - - it('should unsubscribe from realtime on unmount', async () => { - const { unmount } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(realtimeService.subscribeToMessages).toHaveBeenCalled(); - }); - - unmount(); - - expect(mockUnsubscribeMessages).toHaveBeenCalled(); - expect(mockUnsubscribeUpdates).toHaveBeenCalled(); - expect(realtimeService.unsubscribeFromConversation).toHaveBeenCalledWith( - mockConversationId - ); - }); - - it('should send message', async () => { - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - await act(async () => { - await result.current.sendMessage('New test message'); - }); - - expect(messageService.sendMessage).toHaveBeenCalledWith({ - conversation_id: mockConversationId, - content: 'New test message', - }); - }); - - it('should handle pagination (loadMore)', async () => { - const olderMessages = [ - { - id: 'msg-0', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - content: 'Older message', - sequence_number: 0, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date(Date.now() - 10000).toISOString(), - isOwn: false, - senderName: 'Other User', - }, - ]; - - // First call returns messages with hasMore=true - (messageService.getMessageHistory as any) - .mockResolvedValueOnce({ - messages: mockMessages, - has_more: true, - cursor: 2, - }) - .mockResolvedValueOnce({ - messages: olderMessages, - has_more: false, - cursor: null, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.hasMore).toBe(true); - - // Load more messages - await act(async () => { - await result.current.loadMore(); - }); - - // Should prepend older messages - expect(result.current.messages).toHaveLength(3); - expect(result.current.messages[0].id).toBe('msg-0'); - expect(result.current.hasMore).toBe(false); - }); - - it('should handle errors', async () => { - const mockError = new Error('Failed to load messages'); - (messageService.getMessageHistory as any).mockRejectedValueOnce(mockError); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.error).toEqual(mockError); - expect(result.current.messages).toEqual([]); - }); - - it('should add new message from realtime subscription', async () => { - let realtimeCallback: ((message: any) => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - (_id: string, callback: (message: any) => void) => { - realtimeCallback = callback; - return vi.fn(); - } - ); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.messages).toHaveLength(2); - - // Simulate new message from realtime - const newMessage = { - id: 'msg-3', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - encrypted_content: 'encrypted-content', - initialization_vector: 'iv', - sequence_number: 3, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - }; - - await act(async () => { - if (realtimeCallback) { - realtimeCallback(newMessage); - } - }); - - // Note: In real implementation, message would be decrypted first - // This test verifies the subscription callback is set up correctly - expect(realtimeCallback).toBeDefined(); - }); - - it('should update message from realtime subscription', async () => { - let realtimeUpdateCallback: - | ((newMessage: any, oldMessage: any) => void) - | undefined; - - (realtimeService.subscribeToMessageUpdates as any).mockImplementation( - (_id: string, callback: (newMessage: any, oldMessage: any) => void) => { - realtimeUpdateCallback = callback; - return vi.fn(); - } - ); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Simulate message update from realtime - const updatedMessage = { - ...mockMessages[0], - encrypted_content: 'updated-encrypted-content', - edited: true, - edited_at: new Date().toISOString(), - }; - - await act(async () => { - if (realtimeUpdateCallback) { - realtimeUpdateCallback(updatedMessage, mockMessages[0]); - } - }); - - expect(realtimeUpdateCallback).toBeDefined(); - }); - - it('should not load more if already loading', async () => { - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: true, - cursor: 2, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Trigger loadMore twice quickly within act to properly batch state updates - await act(async () => { - const promise1 = result.current.loadMore(); - const promise2 = result.current.loadMore(); - await Promise.all([promise1, promise2]); - }); - - // Should only call getMessageHistory twice (initial + one loadMore) - expect(messageService.getMessageHistory).toHaveBeenCalledTimes(2); - }); - - it('should not load more if no more messages', async () => { - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: false, - cursor: null, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.hasMore).toBe(false); - - await act(async () => { - await result.current.loadMore(); - }); - - // Should only call once (initial load) - expect(messageService.getMessageHistory).toHaveBeenCalledTimes(1); - }); - - // ====================================================================== - // Bug fix regression tests - // ====================================================================== - - it('Bug #1: should deduplicate messages from realtime subscription', async () => { - let realtimeCallback: ((message: any) => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - ( - _id: string, - callback: (message: any) => void, - _onReconnect?: () => void - ) => { - realtimeCallback = callback; - return vi.fn(); - } - ); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.messages).toHaveLength(2); - - // Simulate realtime delivering a message that was already loaded (e.g. after reconnect) - // The decryptSingleMessage mock returns null, so we can't test the full flow here, - // but we verify the callback exists and deduplication logic is in place - expect(realtimeCallback).toBeDefined(); - - // Directly test deduplication via the state setter: calling loadMessages again - // should replace, not duplicate, existing messages - await act(async () => { - // Trigger a second loadMessages (simulating reconnect catch-up) - (messageService.getMessageHistory as any).mockResolvedValueOnce({ - messages: mockMessages, // Same messages - has_more: false, - cursor: null, - }); - }); - - // Messages should not be duplicated - expect(result.current.messages).toHaveLength(2); - }); - - it('Bug #2: should pass onReconnect callback to subscribeToMessages', async () => { - let onReconnectCallback: (() => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - (_id: string, _callback: any, onReconnect?: () => void) => { - onReconnectCallback = onReconnect; - return vi.fn(); - } - ); - - renderHook(() => useConversationRealtime(mockConversationId)); - - await waitFor(() => { - expect(onReconnectCallback).toBeDefined(); - }); - - // Reset call count - (messageService.getMessageHistory as any).mockClear(); - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: false, - cursor: null, - }); - - // Simulate reconnection by calling the callback - await act(async () => { - onReconnectCallback!(); - }); - - // Should have refetched messages on reconnect - expect(messageService.getMessageHistory).toHaveBeenCalledWith( - mockConversationId - ); - }); - - it('Bug #3: should add sent message to state immediately (optimistic update)', async () => { - const sentMessage = { - id: 'msg-new', - conversation_id: mockConversationId, - sender_id: mockUserId, - encrypted_content: 'encrypted', - initialization_vector: 'iv', - sequence_number: 3, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - key_version: 1, - is_system_message: false, - system_message_type: null, - }; - - (messageService.sendMessage as any).mockResolvedValue({ - message: sentMessage, - queued: false, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - const initialCount = result.current.messages.length; - - await act(async () => { - await result.current.sendMessage('Hello world'); - }); - - // Sent message should appear immediately via optimistic update - expect(result.current.messages.length).toBe(initialCount + 1); - const lastMsg = result.current.messages[result.current.messages.length - 1]; - expect(lastMsg.id).toBe('msg-new'); - expect(lastMsg.content).toBe('Hello world'); - expect(lastMsg.isOwn).toBe(true); - }); - - it('Bug #5: should sort messages by sequence_number after realtime delivery', async () => { - // Start with messages at seq 1 and 3 (gap at 2) - const gappedMessages = [ - { ...mockMessages[0], sequence_number: 1 }, - { ...mockMessages[1], sequence_number: 3 }, - ]; - - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: gappedMessages, - has_more: false, - cursor: null, - }); - - const sentMessage = { - id: 'msg-seq2', - conversation_id: mockConversationId, - sender_id: mockUserId, - encrypted_content: 'encrypted', - initialization_vector: 'iv', - sequence_number: 2, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - key_version: 1, - is_system_message: false, - system_message_type: null, - }; - - (messageService.sendMessage as any).mockResolvedValue({ - message: sentMessage, - queued: false, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Send message with sequence_number=2 (should be inserted between 1 and 3) - await act(async () => { - await result.current.sendMessage('Middle message'); - }); - - // Verify messages are sorted by sequence_number - const seqNumbers = result.current.messages.map((m) => m.sequence_number); - expect(seqNumbers).toEqual([1, 2, 3]); - }); - - it('Bug fix: should show placeholder for undecryptable realtime messages instead of dropping them', async () => { - let realtimeCallback: ((message: any) => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - ( - _id: string, - callback: (message: any) => void, - _onReconnect?: () => void - ) => { - realtimeCallback = callback; - return vi.fn(); - } - ); - - // Mock supabase auth to return a user (so decryptSingleMessage doesn't return null at the !user check) - const mockSupabaseWithConv = { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: { id: mockUserId } }, - error: null, - }), - }, - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { - participant_1_id: mockUserId, - participant_2_id: 'other-user-id', - }, - error: null, - }), - }), - }), - }), - }; - (createClient as any).mockReturnValue(mockSupabaseWithConv); - - // Keys unavailable — ensureKeys returns null, triggering the placeholder path - (keyManagementService.ensureKeys as any).mockResolvedValue(null); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Simulate realtime delivering an encrypted message that can't be decrypted - const encryptedMessage = { - id: 'msg-undecryptable', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - encrypted_content: 'encrypted-content', - initialization_vector: 'iv', - sequence_number: 99, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - key_version: 1, - is_system_message: false, - system_message_type: null, - }; - - await act(async () => { - if (realtimeCallback) { - realtimeCallback(encryptedMessage); - } - // Allow async decryptSingleMessage to complete - await new Promise((r) => setTimeout(r, 50)); - }); - - // The message should appear with decryptionError, NOT be silently dropped - const undecryptableMsg = result.current.messages.find( - (m) => m.id === 'msg-undecryptable' - ); - expect(undecryptableMsg).toBeDefined(); - expect(undecryptableMsg?.decryptionError).toBe(true); - expect(undecryptableMsg?.content).toContain('Unable to decrypt'); - }); -}); diff --git a/src/hooks/useConversationRealtime.ts b/src/hooks/useConversationRealtime.ts deleted file mode 100644 index c17cd097..00000000 --- a/src/hooks/useConversationRealtime.ts +++ /dev/null @@ -1,206 +0,0 @@ -'use client'; - -/** - * useConversationRealtime Hook - * Task: T112 - * - * Manages real-time message subscriptions and state for a conversation. - * Delegates decryption to decrypt-message and channel lifecycle to - * useConversationRealtimeSync. - */ - -import { useState, useCallback, useRef } from 'react'; -import { messageService } from '@/services/messaging/message-service'; -import type { - DecryptedMessage, - UseConversationRealtimeReturn, -} from '@/types/messaging'; -import { createClient } from '@/lib/supabase/client'; -import { getProfile } from '@/lib/messaging/decryption-cache'; -import { - decryptMessage, - type ConversationDataRef, -} from '@/lib/messaging/decrypt-message'; -import { useConversationRealtimeSync } from './useConversationRealtimeSync'; - -export function useConversationRealtime( - conversationId: string -): UseConversationRealtimeReturn { - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [cursor, setCursor] = useState(null); - const supabase = createClient(); - - const isMountedRef = useRef(true); - const loadingRef = useRef(false); - const conversationDataRef = useRef(null); - - // --- Data loading ---------------------------------------------------------- - - const loadMessages = useCallback(async () => { - try { - setLoading(true); - const result = await messageService.getMessageHistory(conversationId); - - if (isMountedRef.current) { - setMessages(result.messages); - setHasMore(result.has_more); - setCursor(result.cursor); - setError(null); - } - } catch (err) { - if (isMountedRef.current) { - setError(err as Error); - } - } finally { - if (isMountedRef.current) { - setLoading(false); - } - } - }, [conversationId]); - - const loadMore = useCallback(async () => { - if (!hasMore || loading || loadingRef.current) return; - - loadingRef.current = true; - try { - setLoading(true); - const result = await messageService.getMessageHistory( - conversationId, - cursor, - 50 - ); - - if (isMountedRef.current) { - setMessages((prev) => [...result.messages, ...prev]); - setHasMore(result.has_more); - setCursor(result.cursor); - } - } catch (err) { - if (isMountedRef.current) { - setError(err as Error); - } - } finally { - loadingRef.current = false; - if (isMountedRef.current) { - setLoading(false); - } - } - }, [conversationId, cursor, hasMore, loading]); - - // --- Realtime subscription (extracted hook) -------------------------------- - - useConversationRealtimeSync({ - conversationId, - supabase, - conversationDataRef, - loadMessages, - setMessages, - }); - - // --- Mutations ------------------------------------------------------------- - - /** - * Send a new message with optimistic update. - * Bug fix: adds message to state immediately instead of waiting for - * realtime subscription (which may not be ready for the first message). - */ - const sendMessage = useCallback( - async (content: string) => { - try { - const result = await messageService.sendMessage({ - conversation_id: conversationId, - content, - }); - - if (result.message && isMountedRef.current) { - const { - data: { user }, - } = await supabase.auth.getUser(); - - const senderProfile = user ? getProfile(user.id) : null; - - const optimisticMsg: DecryptedMessage = { - id: result.message.id, - conversation_id: conversationId, - sender_id: result.message.sender_id, - content, - sequence_number: result.message.sequence_number, - deleted: false, - edited: false, - edited_at: null, - delivered_at: result.message.delivered_at, - read_at: null, - created_at: result.message.created_at, - isOwn: true, - senderName: - senderProfile?.display_name || senderProfile?.username || 'You', - }; - - setMessages((prev) => { - if (prev.some((m) => m.id === optimisticMsg.id)) return prev; - return [...prev, optimisticMsg].sort( - (a, b) => a.sequence_number - b.sequence_number - ); - }); - } - } catch (err) { - setError(err as Error); - throw err; - } - }, - [conversationId, supabase] - ); - - /** - * Edit a message within 15-minute window (T105) - */ - const editMessage = useCallback( - async (message_id: string, new_content: string) => { - try { - await messageService.editMessage({ message_id, new_content }); - setMessages((prev) => - prev.map((msg) => - msg.id === message_id - ? { - ...msg, - content: new_content, - edited: true, - edited_at: new Date().toISOString(), - } - : msg - ) - ); - } catch (err) { - setError(err as Error); - throw err; - } - }, - [] - ); - - /** - * Delete a message within 15-minute window (T106) - */ - const deleteMessage = useCallback(async (message_id: string) => { - try { - await messageService.deleteMessage(message_id); - } catch (err) { - setError(err as Error); - throw err; - } - }, []); - - return { - messages, - loading, - error, - sendMessage, - editMessage, - deleteMessage, - loadMore, - hasMore, - }; -} diff --git a/src/hooks/useConversationRealtimeSync.ts b/src/hooks/useConversationRealtimeSync.ts deleted file mode 100644 index 18b037f1..00000000 --- a/src/hooks/useConversationRealtimeSync.ts +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -/** - * useConversationRealtimeSync — realtime subscription lifecycle for a conversation. - * - * Extracted from useConversationRealtime to keep each hook focused. - * Owns: channel subscribe / unsubscribe, reconnection catch-up, dedup, ordering. - * - * Includes a 10-second polling fallback that catches messages silently dropped - * by Supabase Realtime under connection contention (e.g. free-tier limits). - */ - -import { useEffect, useCallback, useRef } from 'react'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import { createLogger } from '@/lib/logger'; -import { realtimeService } from '@/lib/messaging/realtime'; -import type { Message, DecryptedMessage } from '@/types/messaging'; -import { - decryptMessage, - upsertMessage, - type ConversationDataRef, -} from '@/lib/messaging/decrypt-message'; - -const logger = createLogger('hooks:conversationRealtimeSync'); - -/** How often the polling fallback fires (ms). */ -const POLL_INTERVAL_MS = 10_000; - -export interface UseConversationRealtimeSyncOptions { - conversationId: string; - supabase: SupabaseClient; - conversationDataRef: ConversationDataRef; - /** Called once on mount + on reconnection to load/reload history. */ - loadMessages: () => Promise; - /** Setter to merge new/updated messages into state. */ - setMessages: React.Dispatch>; -} - -/** - * Subscribe to INSERT and UPDATE events on messages for a conversation. - * Handles dedup, ordering, reconnection catch-up, and periodic polling fallback. - */ -export function useConversationRealtimeSync({ - conversationId, - supabase, - conversationDataRef, - loadMessages, - setMessages, -}: UseConversationRealtimeSyncOptions): void { - const isMountedRef = useRef(true); - const lastRealtimeEventRef = useRef(0); - - const decryptSingle = useCallback( - async (msg: Message): Promise => - decryptMessage(msg, conversationId, supabase, conversationDataRef), - [conversationId, supabase, conversationDataRef] - ); - - // Mark that Realtime delivered something — resets the polling skip window - const touchRealtime = useCallback(() => { - lastRealtimeEventRef.current = Date.now(); - }, []); - - useEffect(() => { - isMountedRef.current = true; - - // Load initial messages - loadMessages(); - - // Subscribe to new messages (with reconnection catch-up) - const unsubscribeMessages = realtimeService.subscribeToMessages( - conversationId, - async (message) => { - touchRealtime(); - const decrypted = await decryptSingle(message); - if (decrypted && isMountedRef.current) { - setMessages((prev) => upsertMessage(prev, decrypted)); - } - }, - () => { - if (isMountedRef.current) { - logger.debug('Reconnected — refetching messages to catch up'); - loadMessages(); - } - }, - () => { - // Signal message subscription readiness via DOM attribute (used by E2E tests) - if (typeof document !== 'undefined' && conversationId) { - document.body.setAttribute( - 'data-messages-subscribed', - conversationId - ); - } - } - ); - - // Subscribe to message updates (edits/deletes) - const unsubscribeUpdates = realtimeService.subscribeToMessageUpdates( - conversationId, - async (newMessage) => { - touchRealtime(); - const decrypted = await decryptSingle(newMessage); - if (decrypted && isMountedRef.current) { - setMessages((prev) => upsertMessage(prev, decrypted)); - } - } - ); - - // ── Polling fallback ────────────────────────────────────────────── - // Supabase Realtime on the free tier can silently drop messages under - // connection contention. This interval re-fetches from the DB every - // POLL_INTERVAL_MS when Realtime hasn't delivered anything recently, - // guaranteeing eventual delivery even if the WebSocket missed an event. - const pollTimer = setInterval(() => { - if (!isMountedRef.current) return; - // Skip if tab is hidden (save API calls) - if (typeof document !== 'undefined' && document.hidden) return; - // Skip if Realtime delivered something within the last interval - if (Date.now() - lastRealtimeEventRef.current < POLL_INTERVAL_MS) return; - - logger.debug('Polling fallback — refetching messages'); - loadMessages().then(() => { - if (typeof document !== 'undefined') { - document.body.setAttribute( - 'data-messages-last-poll', - new Date().toISOString() - ); - } - }); - }, POLL_INTERVAL_MS); - - return () => { - isMountedRef.current = false; - unsubscribeMessages(); - unsubscribeUpdates(); - clearInterval(pollTimer); - realtimeService.unsubscribeFromConversation(conversationId); - if (typeof document !== 'undefined') { - document.body.removeAttribute('data-messages-subscribed'); - document.body.removeAttribute('data-messages-last-poll'); - } - }; - }, [conversationId, loadMessages, decryptSingle, setMessages, touchRealtime]); -} diff --git a/src/lib/messaging/__tests__/decrypt-message.test.ts b/src/lib/messaging/__tests__/decrypt-message.test.ts deleted file mode 100644 index ff1a6d82..00000000 --- a/src/lib/messaging/__tests__/decrypt-message.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Tests for decrypt-message module - * - * Covers: - * - Successful decryption path - * - Placeholder when keys are unavailable (makePlaceholder path) - * - isOwn flag after key rotation (catch block path) - * - Null return when user is not authenticated - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { decryptMessage, type ConversationDataRef } from '../decrypt-message'; -import type { Message } from '@/types/messaging'; - -// --- Mocks ------------------------------------------------------------------- - -const mockDecryptMessage = vi.fn(); -const mockDeriveSharedSecret = vi.fn(); - -vi.mock('@/lib/messaging/encryption', () => ({ - encryptionService: { - decryptMessage: (...args: unknown[]) => mockDecryptMessage(...args), - deriveSharedSecret: (...args: unknown[]) => mockDeriveSharedSecret(...args), - }, -})); - -const mockEnsureKeys = vi.fn(); -const mockGetUserPublicKey = vi.fn(); - -vi.mock('@/services/messaging/key-service', () => ({ - keyManagementService: { - ensureKeys: (...args: unknown[]) => mockEnsureKeys(...args), - getUserPublicKey: (...args: unknown[]) => mockGetUserPublicKey(...args), - }, -})); - -vi.mock('@/lib/supabase/messaging-client', () => ({ - createMessagingClient: () => ({ - from: () => ({ - select: () => ({ - eq: () => ({ - single: vi.fn().mockResolvedValue({ - data: { participant_1_id: 'user-1', participant_2_id: 'user-2' }, - error: null, - }), - }), - }), - }), - }), -})); - -const mockProfileStore = new Map< - string, - { username: string | null; display_name: string | null } ->(); - -vi.mock('@/lib/messaging/decryption-cache', () => { - const sharedSecretCache = new Map(); - const privateKeyCache = new Map(); - return { - sharedSecretCache, - privateKeyCache, - getProfile: (id: string) => mockProfileStore.get(id), - setProfile: ( - id: string, - data: { username: string | null; display_name: string | null } - ) => mockProfileStore.set(id, data), - invalidateProfile: (id: string) => mockProfileStore.delete(id), - deduplicateProfile: (_id: string, fn: () => Promise) => fn(), - deduplicateSecret: (_key: string, fn: () => Promise) => fn(), - }; -}); - -// Import the mock instances so we can clear them between tests -import { - sharedSecretCache, - privateKeyCache, -} from '@/lib/messaging/decryption-cache'; - -vi.mock('@/lib/logger', () => ({ - createLogger: () => ({ - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -// --- Helpers ----------------------------------------------------------------- - -const CURRENT_USER_ID = 'user-1'; -const OTHER_USER_ID = 'user-2'; - -function makeMessage(overrides?: Partial): Message { - return { - id: 'msg-1', - conversation_id: 'conv-1', - sender_id: OTHER_USER_ID, - encrypted_content: 'base64-ciphertext', - initialization_vector: 'base64-iv', - sequence_number: 1, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: '2026-03-19T00:00:00Z', - key_version: 1, - is_system_message: false, - system_message_type: null, - ...overrides, - }; -} - -function makeSupabase(userId: string | null = CURRENT_USER_ID) { - return { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - error: null, - }), - }, - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { username: 'alice', display_name: 'Alice' }, - error: null, - }), - }), - }), - }), - } as any; -} - -function makeRef(data?: ConversationDataRef['current']): ConversationDataRef { - return { current: data ?? null }; -} - -// --- Tests ------------------------------------------------------------------- - -describe('decryptMessage', () => { - beforeEach(() => { - vi.clearAllMocks(); - sharedSecretCache.clear(); - privateKeyCache.clear(); - mockProfileStore.clear(); - }); - - it('returns null when user is not authenticated', async () => { - const supabase = makeSupabase(null); - const result = await decryptMessage( - makeMessage(), - 'conv-1', - supabase, - makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }) - ); - expect(result).toBeNull(); - }); - - it('returns placeholder with correct isOwn when keys are unavailable', async () => { - mockEnsureKeys.mockResolvedValue(null); - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result).not.toBeNull(); - expect(result!.decryptionError).toBe(true); - expect(result!.isOwn).toBe(true); - expect(result!.content).toContain('Unable to decrypt'); - }); - - it('returns placeholder with isOwn=false for other user when keys unavailable', async () => { - mockEnsureKeys.mockResolvedValue(null); - - const otherMessage = makeMessage({ sender_id: OTHER_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(otherMessage, 'conv-1', supabase, ref); - - expect(result!.decryptionError).toBe(true); - expect(result!.isOwn).toBe(false); - }); - - // --- Key rotation scenario ------------------------------------------------- - - it('preserves isOwn=true in catch block when own message fails decryption (key rotation)', async () => { - // Simulate: keys are available but decryption fails (old message, new keys) - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue({ kty: 'EC', crv: 'P-256' }); - - // crypto.subtle.importKey for the other user's public key - const mockImportedKey = {} as CryptoKey; - vi.spyOn(crypto.subtle, 'importKey').mockResolvedValue(mockImportedKey); - - // deriveSharedSecret returns a CryptoKey - const mockSharedSecret = {} as CryptoKey; - mockDeriveSharedSecret.mockResolvedValue(mockSharedSecret); - - // decryptMessage THROWS — simulating AES-GCM auth failure from wrong shared secret - mockDecryptMessage.mockRejectedValue( - new Error('The operation failed for an operation-specific reason') - ); - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result).not.toBeNull(); - expect(result!.decryptionError).toBe(true); - expect(result!.content).toBe('Encrypted with previous keys'); - // This is the bug fix: isOwn must be true for own messages even after rotation - expect(result!.isOwn).toBe(true); - expect(result!.sender_id).toBe(CURRENT_USER_ID); - }); - - it('preserves isOwn=false in catch block for other user message after key rotation', async () => { - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue({ kty: 'EC', crv: 'P-256' }); - - const mockImportedKey = {} as CryptoKey; - vi.spyOn(crypto.subtle, 'importKey').mockResolvedValue(mockImportedKey); - - const mockSharedSecret = {} as CryptoKey; - mockDeriveSharedSecret.mockResolvedValue(mockSharedSecret); - - // AES-GCM auth failure - mockDecryptMessage.mockRejectedValue( - new Error('The operation failed for an operation-specific reason') - ); - - const otherMessage = makeMessage({ sender_id: OTHER_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(otherMessage, 'conv-1', supabase, ref); - - expect(result!.decryptionError).toBe(true); - expect(result!.content).toBe('Encrypted with previous keys'); - expect(result!.isOwn).toBe(false); - }); - - // --- Happy path ------------------------------------------------------------ - - it('decrypts successfully and returns correct isOwn for own message', async () => { - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue({ kty: 'EC', crv: 'P-256' }); - - const mockImportedKey = {} as CryptoKey; - vi.spyOn(crypto.subtle, 'importKey').mockResolvedValue(mockImportedKey); - - const mockSharedSecret = {} as CryptoKey; - mockDeriveSharedSecret.mockResolvedValue(mockSharedSecret); - mockDecryptMessage.mockResolvedValue('Hello world'); - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result).not.toBeNull(); - expect(result!.content).toBe('Hello world'); - expect(result!.isOwn).toBe(true); - expect(result!.decryptionError).toBeUndefined(); - }); - - it('returns placeholder with correct isOwn when other user has no public key', async () => { - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue(null); // No public key - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result!.decryptionError).toBe(true); - expect(result!.isOwn).toBe(true); - expect(result!.content).toContain('sender encryption keys unavailable'); - }); -}); diff --git a/src/lib/messaging/__tests__/decryption-cache.test.ts b/src/lib/messaging/__tests__/decryption-cache.test.ts deleted file mode 100644 index 4226ec2b..00000000 --- a/src/lib/messaging/__tests__/decryption-cache.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Tests for decryption-cache module - * - * Covers: - * - Profile TTL: cached profiles expire after PROFILE_TTL_MS - * - Profile invalidation: invalidateProfile() clears a single entry - * - Request deduplication: concurrent fetches coalesce into one call - * - clearDecryptionCaches() clears everything including pending maps - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - sharedSecretCache, - privateKeyCache, - getProfile, - setProfile, - invalidateProfile, - deduplicateProfile, - deduplicateSecret, - clearDecryptionCaches, - PROFILE_TTL_MS, -} from '../decryption-cache'; - -describe('decryption-cache', () => { - beforeEach(() => { - clearDecryptionCaches(); - }); - - describe('profile TTL', () => { - it('returns cached profile within TTL window', () => { - setProfile('user-1', { username: 'alice', display_name: 'Alice' }); - const result = getProfile('user-1'); - expect(result).toEqual({ username: 'alice', display_name: 'Alice' }); - }); - - it('returns undefined for expired profile', () => { - setProfile('user-1', { username: 'alice', display_name: 'Alice' }); - - // Advance time past TTL - vi.useFakeTimers(); - vi.advanceTimersByTime(PROFILE_TTL_MS + 1); - - const result = getProfile('user-1'); - expect(result).toBeUndefined(); - - vi.useRealTimers(); - }); - - it('returns undefined for unknown sender', () => { - expect(getProfile('nonexistent')).toBeUndefined(); - }); - }); - - describe('invalidateProfile', () => { - it('removes a single profile entry immediately', () => { - setProfile('user-1', { username: 'alice', display_name: 'Alice' }); - setProfile('user-2', { username: 'bob', display_name: 'Bob' }); - - invalidateProfile('user-1'); - - expect(getProfile('user-1')).toBeUndefined(); - expect(getProfile('user-2')).toEqual({ - username: 'bob', - display_name: 'Bob', - }); - }); - }); - - describe('deduplicateProfile', () => { - it('coalesces concurrent calls for the same sender into one fetch', async () => { - const fetcher = vi - .fn() - .mockResolvedValue({ username: 'alice', display_name: 'Alice' }); - - // Fire 3 concurrent calls - const [r1, r2, r3] = await Promise.all([ - deduplicateProfile('user-1', fetcher), - deduplicateProfile('user-1', fetcher), - deduplicateProfile('user-1', fetcher), - ]); - - expect(fetcher).toHaveBeenCalledTimes(1); - expect(r1).toEqual({ username: 'alice', display_name: 'Alice' }); - expect(r2).toEqual({ username: 'alice', display_name: 'Alice' }); - expect(r3).toEqual({ username: 'alice', display_name: 'Alice' }); - }); - - it('allows a new fetch after the first completes', async () => { - const fetcher = vi - .fn() - .mockResolvedValue({ username: 'alice', display_name: 'Alice' }); - - await deduplicateProfile('user-1', fetcher); - await deduplicateProfile('user-1', fetcher); - - // Two sequential calls = two fetcher invocations (no pending overlap) - expect(fetcher).toHaveBeenCalledTimes(2); - }); - - it('cleans up pending entry even if fetcher throws', async () => { - const fetcher = vi.fn().mockRejectedValue(new Error('Network error')); - - await expect(deduplicateProfile('user-1', fetcher)).rejects.toThrow( - 'Network error' - ); - - // Pending map should be clean — next call should invoke fetcher again - const fetcher2 = vi - .fn() - .mockResolvedValue({ username: null, display_name: null }); - await deduplicateProfile('user-1', fetcher2); - expect(fetcher2).toHaveBeenCalledTimes(1); - }); - }); - - describe('deduplicateSecret', () => { - it('coalesces concurrent derivations for the same cache key', async () => { - const mockSecret = {} as CryptoKey; - const deriver = vi.fn().mockResolvedValue(mockSecret); - - const [r1, r2] = await Promise.all([ - deduplicateSecret('conv:user', deriver), - deduplicateSecret('conv:user', deriver), - ]); - - expect(deriver).toHaveBeenCalledTimes(1); - expect(r1).toBe(mockSecret); - expect(r2).toBe(mockSecret); - }); - }); - - describe('clearDecryptionCaches', () => { - it('clears all caches', () => { - sharedSecretCache.set('key', {} as CryptoKey); - privateKeyCache.set('user', {} as CryptoKey); - setProfile('user', { username: 'test', display_name: 'Test' }); - - clearDecryptionCaches(); - - expect(sharedSecretCache.size).toBe(0); - expect(privateKeyCache.size).toBe(0); - expect(getProfile('user')).toBeUndefined(); - }); - }); -}); diff --git a/src/lib/messaging/decrypt-message.ts b/src/lib/messaging/decrypt-message.ts deleted file mode 100644 index 84816cea..00000000 --- a/src/lib/messaging/decrypt-message.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * decrypt-message — stateless (cache-aware) single-message decryption. - * - * Extracted from useConversationRealtime so the hook stays under 400 lines. - * Caches: conversation participants, private key, shared secret, sender profile. - */ - -import type { SupabaseClient } from '@supabase/supabase-js'; -import { createLogger } from '@/lib/logger'; -import { encryptionService } from '@/lib/messaging/encryption'; -import { keyManagementService } from '@/services/messaging/key-service'; -import { createMessagingClient } from '@/lib/supabase/messaging-client'; -import type { Message, DecryptedMessage } from '@/types/messaging'; -import { - sharedSecretCache, - privateKeyCache, - getProfile, - setProfile, - deduplicateProfile, - deduplicateSecret, -} from '@/lib/messaging/decryption-cache'; - -const logger = createLogger('messaging:decrypt-message'); - -/** Ref-like mutable holder so the caller can share conversation data across calls. */ -export interface ConversationDataRef { - current: { participant_1_id: string; participant_2_id: string } | null; -} - -/** Build a placeholder DecryptedMessage when decryption is impossible. */ -function makePlaceholder( - msg: Message, - userId: string, - reason: string -): DecryptedMessage { - return { - id: msg.id, - conversation_id: msg.conversation_id, - sender_id: msg.sender_id, - content: reason, - sequence_number: msg.sequence_number, - deleted: msg.deleted, - edited: msg.edited, - edited_at: msg.edited_at, - delivered_at: msg.delivered_at, - read_at: msg.read_at, - created_at: msg.created_at, - isOwn: msg.sender_id === userId, - senderName: 'Unknown', - decryptionError: true, - }; -} - -/** - * Decrypt a single Message → DecryptedMessage, using module-level caches - * for shared secret, private key, and sender profile. - */ -export async function decryptMessage( - msg: Message, - conversationId: string, - supabase: SupabaseClient, - conversationDataRef: ConversationDataRef -): Promise { - let userId: string | null = null; - try { - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) return null; - userId = user.id; - - // Get conversation details (cached in ref) - let conversation = conversationDataRef.current; - if (!conversation) { - const msgClient = createMessagingClient(supabase); - const result = await msgClient - .from('conversations') - .select('participant_1_id, participant_2_id') - .eq('id', conversationId) - .single(); - - conversation = result.data as { - participant_1_id: string; - participant_2_id: string; - } | null; - - if (!conversation) return null; - conversationDataRef.current = conversation; - } - - // Determine other participant - const otherParticipantId = - conversation.participant_1_id === user.id - ? conversation.participant_2_id - : conversation.participant_1_id; - - // Check shared secret cache first (most expensive operation) - const cacheKey = `${conversationId}:${otherParticipantId}`; - let sharedSecret = sharedSecretCache.get(cacheKey); - - if (!sharedSecret) { - const derived = await deduplicateSecret(cacheKey, async () => { - // Get private key from memory (derived during sign-in) - let privateKey = privateKeyCache.get(user.id); - if (!privateKey) { - const derivedKeys = await keyManagementService.ensureKeys(user.id); - if (!derivedKeys) { - return null; - } - privateKey = derivedKeys.privateKey; - privateKeyCache.set(user.id, privateKey); - } - - // Get other participant's public key - const otherPublicKey = - await keyManagementService.getUserPublicKey(otherParticipantId); - if (!otherPublicKey) return null; - - const otherPublicKeyCrypto = await crypto.subtle.importKey( - 'jwk', - otherPublicKey, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); - - const secret = await encryptionService.deriveSharedSecret( - privateKey, - otherPublicKeyCrypto - ); - sharedSecretCache.set(cacheKey, secret); - logger.debug('Cached shared secret for conversation', { - conversationId, - }); - return secret; - }); - - if (!derived) { - // Determine which step failed for the right placeholder message - const hasPrivateKey = - privateKeyCache.has(user.id) || - (await keyManagementService.ensureKeys(user.id)); - if (!hasPrivateKey) { - logger.warn( - 'No derived keys available - user may need to re-authenticate' - ); - return makePlaceholder( - msg, - user.id, - 'Unable to decrypt — please sign in again' - ); - } - logger.warn('Other participant has no public key', { - otherParticipantId, - }); - return makePlaceholder( - msg, - user.id, - 'Unable to decrypt — sender encryption keys unavailable' - ); - } - sharedSecret = derived; - } - - // Decrypt message (fast once we have shared secret) - const content = await encryptionService.decryptMessage( - msg.encrypted_content, - msg.initialization_vector, - sharedSecret - ); - - // Get sender profile (cached with TTL, deduplicated) - let senderProfile = getProfile(msg.sender_id); - if (!senderProfile) { - senderProfile = await deduplicateProfile(msg.sender_id, async () => { - const { data } = await supabase - .from('user_profiles') - .select('username, display_name') - .eq('id', msg.sender_id) - .single(); - - const profile = data || { username: null, display_name: null }; - setProfile(msg.sender_id, profile); - return profile; - }); - } - - return { - id: msg.id, - conversation_id: msg.conversation_id, - sender_id: msg.sender_id, - content, - sequence_number: msg.sequence_number, - deleted: msg.deleted, - edited: msg.edited, - edited_at: msg.edited_at, - delivered_at: msg.delivered_at, - read_at: msg.read_at, - created_at: msg.created_at, - isOwn: msg.sender_id === user.id, - senderName: - senderProfile?.display_name || senderProfile?.username || 'Unknown', - }; - } catch (err) { - logger.error('Failed to decrypt message', { error: err }); - return { - id: msg.id, - conversation_id: msg.conversation_id, - sender_id: msg.sender_id, - content: 'Encrypted with previous keys', - sequence_number: msg.sequence_number, - deleted: msg.deleted, - edited: msg.edited, - edited_at: msg.edited_at, - delivered_at: msg.delivered_at, - read_at: msg.read_at, - created_at: msg.created_at, - isOwn: userId !== null && msg.sender_id === userId, - senderName: 'Unknown', - decryptionError: true, - }; - } -} - -/** - * Upsert a decrypted message into the list: replace if the ID already - * exists, otherwise insert; then sort by sequence_number (falling back to - * created_at for seq=0 offline-queued placeholders). - * - * Idempotent by design — Realtime can redeliver on reconnect and we must - * not duplicate. Also refuses to regress a plaintext message back to an - * error placeholder if a later redelivery fails to decrypt. - */ -export function upsertMessage( - prev: DecryptedMessage[], - incoming: DecryptedMessage -): DecryptedMessage[] { - const idx = prev.findIndex((m) => m.id === incoming.id); - let next: DecryptedMessage[]; - - if (idx >= 0) { - const existing = prev[idx]; - // Don't downgrade plaintext → "could not decrypt" placeholder. - const merged = - incoming.decryptionError && !existing.decryptionError - ? { - ...incoming, - content: existing.content, - isOwn: existing.isOwn, - senderName: existing.senderName, - decryptionError: false, - } - : incoming; - next = [...prev]; - next[idx] = merged; - } else { - next = [...prev, incoming]; - } - - return next.sort((a, b) => { - if (a.sequence_number !== b.sequence_number) { - return a.sequence_number - b.sequence_number; - } - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - }); -} diff --git a/src/lib/messaging/decryption-cache.ts b/src/lib/messaging/decryption-cache.ts deleted file mode 100644 index e66efe35..00000000 --- a/src/lib/messaging/decryption-cache.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Module-level decryption caches for the messaging system. - * - * Extracted into a standalone module to avoid circular imports between - * key-service (which must clear caches on sign-out / key rotation) and - * useConversationRealtime (which populates and reads the caches). - */ - -/** How long a cached profile stays valid before re-fetching (10 minutes). */ -const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; - -export interface ProfileData { - username: string | null; - display_name: string | null; -} - -interface CachedProfile { - data: ProfileData; - cachedAt: number; -} - -// Shared secret cache: Key = `${conversationId}:${otherParticipantId}` -export const sharedSecretCache = new Map(); - -// Imported private key cache: Key = userId -export const privateKeyCache = new Map(); - -// User profile cache with TTL: Key = senderId -const profileCache = new Map(); - -// Pending request maps for deduplication of concurrent fetches -const pendingProfiles = new Map>(); -const pendingSecrets = new Map>(); - -/** Read a profile from cache, returning undefined if missing or expired. */ -export function getProfile(senderId: string): ProfileData | undefined { - const entry = profileCache.get(senderId); - if (!entry) return undefined; - if (Date.now() - entry.cachedAt > PROFILE_CACHE_TTL_MS) { - profileCache.delete(senderId); - return undefined; - } - return entry.data; -} - -/** Write a profile to cache with a timestamp. */ -export function setProfile(senderId: string, data: ProfileData): void { - profileCache.set(senderId, { data, cachedAt: Date.now() }); -} - -/** Immediately invalidate a single profile entry (e.g. after the current user updates their own name). */ -export function invalidateProfile(userId: string): void { - profileCache.delete(userId); -} - -/** - * Deduplicate concurrent profile fetches for the same sender. - * The first caller runs the fetcher; subsequent callers await the same Promise. - */ -export function deduplicateProfile( - senderId: string, - fetcher: () => Promise -): Promise { - const pending = pendingProfiles.get(senderId); - if (pending) return pending; - - const promise = fetcher().finally(() => pendingProfiles.delete(senderId)); - pendingProfiles.set(senderId, promise); - return promise; -} - -/** - * Deduplicate concurrent shared-secret derivations for the same conversation+participant. - * The first caller runs the deriver; subsequent callers await the same Promise. - */ -export function deduplicateSecret( - cacheKey: string, - deriver: () => Promise -): Promise { - const pending = pendingSecrets.get(cacheKey); - if (pending) return pending; - - const promise = deriver().finally(() => pendingSecrets.delete(cacheKey)); - pendingSecrets.set(cacheKey, promise); - return promise; -} - -/** - * Clear all module-level decryption caches. - * Must be called on sign-out and key rotation so stale shared secrets - * derived from old key pairs are never reused. - */ -export function clearDecryptionCaches(): void { - sharedSecretCache.clear(); - privateKeyCache.clear(); - profileCache.clear(); - pendingProfiles.clear(); - pendingSecrets.clear(); -} - -/** Exported for testing only. */ -export const PROFILE_TTL_MS = PROFILE_CACHE_TTL_MS; diff --git a/src/services/messaging/__tests__/key-service.test.ts b/src/services/messaging/__tests__/key-service.test.ts index a35013c5..9d527ea9 100644 --- a/src/services/messaging/__tests__/key-service.test.ts +++ b/src/services/messaging/__tests__/key-service.test.ts @@ -31,7 +31,6 @@ import { KeyMismatchError, ConnectionError, } from '@/types/messaging'; -// decryption-cache clearing is handled via onKeysChanged listener in decrypt-message.ts // Use vi.hoisted to create mock that can be referenced in vi.mock (vitest 4.0 pattern) const { mockKeyDerivationInstance } = vi.hoisted(() => ({ @@ -46,7 +45,6 @@ const { mockKeyDerivationInstance } = vi.hoisted(() => ({ vi.mock('@/lib/supabase/client'); vi.mock('@/lib/supabase/messaging-client'); vi.mock('@/lib/messaging/encryption'); -// decryption-cache no longer imported by key-service (uses onKeysChanged pattern) vi.mock('@/lib/messaging/key-derivation', () => ({ KeyDerivationService: class MockKeyDerivationService { generateSalt = mockKeyDerivationInstance.generateSalt; @@ -470,15 +468,6 @@ describe('KeyManagementService', () => { keyService.clearKeys(); expect(keyService.getCurrentKeys()).toBeNull(); }); - - it('should notify key-change listeners on clearKeys so caches can be flushed', async () => { - const listener = vi.fn(); - keyService.onKeysChanged(listener); - - keyService.clearKeys(); - - expect(listener).toHaveBeenCalledTimes(1); - }); }); describe('revokeKeys()', () => { diff --git a/src/services/messaging/key-service.ts b/src/services/messaging/key-service.ts index b9979abe..2a4aea35 100644 --- a/src/services/messaging/key-service.ts +++ b/src/services/messaging/key-service.ts @@ -32,9 +32,6 @@ import { const logger = createLogger('messaging:keys'); -/** Listener fired whenever the active key pair changes (derived, rotated, or cleared). */ -export type KeyChangeListener = () => void; - export class KeyManagementService { /** In-memory storage for derived keys (cleared on logout) */ private derivedKeys: DerivedKeyPair | null = null; @@ -47,35 +44,6 @@ export class KeyManagementService { /** Key derivation service (Argon2id) */ private keyDerivationService = new KeyDerivationService(); - /** Listeners notified when derivedKeys changes (set/rotate/clear). - * Downstream caches (e.g., shared-secret cache in useConversationRealtime) - * subscribe so they can invalidate on re-auth or key rotation. */ - private keyChangeListeners = new Set(); - - /** - * Subscribe to key lifecycle changes. Fires on: - * - initializeKeys / deriveKeys (new keys loaded) - * - rotateKeys (keys replaced) - * - clearKeys / revokeKeys (keys cleared) - * - restoreKeysFromSession (keys rehydrated from localStorage) - * - * @returns unsubscribe function - */ - onKeysChanged(listener: KeyChangeListener): () => void { - this.keyChangeListeners.add(listener); - return () => this.keyChangeListeners.delete(listener); - } - - private notifyKeyChange(): void { - this.keyChangeListeners.forEach((l) => { - try { - l(); - } catch (err) { - logger.error('Key-change listener threw', { error: err }); - } - }); - } - /** * Initialize encryption keys for NEW user (first login after registration) * Task: T007 (Feature 032) @@ -157,8 +125,6 @@ export class KeyManagementService { // Step 4: Store in memory + localStorage cache (per-user) this.derivedKeys = keyPair; await this.cacheKeysToStorage(keyPair, user.id); - this.notifyKeyChange(); - logger.info('Keys initialized for user', { userId: user.id }); return keyPair; } catch (error) { @@ -279,8 +245,6 @@ export class KeyManagementService { // Step 4: Store in memory + localStorage cache (per-user) this.derivedKeys = keyPair; await this.cacheKeysToStorage(keyPair, user.id); - this.notifyKeyChange(); - logger.info('Keys derived for user', { userId: user.id }); return keyPair; } catch (error) { @@ -334,7 +298,6 @@ export class KeyManagementService { } catch { // SSR or restricted context — ignore } - this.notifyKeyChange(); logger.debug('Keys cleared from memory'); } @@ -374,7 +337,6 @@ export class KeyManagementService { [] ); this.derivedKeys = { privateKey, publicKey, publicKeyJwk, salt }; - this.notifyKeyChange(); console.log('[key-cache] Keys restored from localStorage'); logger.debug('Keys restored from localStorage cache'); return true; @@ -599,8 +561,6 @@ export class KeyManagementService { // Update in-memory keys (password-derived keys are never persisted to IndexedDB) this.derivedKeys = keyPair; await this.cacheKeysToStorage(keyPair, user.id); - this.notifyKeyChange(); - logger.info('Keys rotated for user', { userId: user.id }); return true; } catch (error) { diff --git a/tests/e2e/messaging/real-time-delivery.spec.ts b/tests/e2e/messaging/real-time-delivery.spec.ts index bf2bca40..95d7de4c 100644 --- a/tests/e2e/messaging/real-time-delivery.spec.ts +++ b/tests/e2e/messaging/real-time-delivery.spec.ts @@ -166,7 +166,7 @@ async function navigateBothToConversation( } // Best-effort wait for Realtime subscription readiness. - // useConversationRealtimeSync sets data-messages-subscribed on document.body + // messages/page.tsx sets data-messages-subscribed on document.body // when the channel reaches SUBSCRIBED. Under free-tier contention the channel // may never reach SUBSCRIBED — the 10s polling fallback in the app guarantees // eventual message delivery regardless, so we proceed after a reasonable wait.