diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..699c882 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +GITHUB_TOKEN= +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5-mini +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-haiku-4-5-20251001 +AI_PROVIDER=heuristic +AI_SEARCH_ASSISTANT_PROVIDER= +AI_SEARCH_ASSISTANT_MODEL= +AI_CVE_INSIGHT_PROVIDER= +AI_CVE_INSIGHT_MODEL= +AI_DAILY_DIGEST_PROVIDER= +AI_DAILY_DIGEST_MODEL= +PROJECTS_FILE= +MONITORED_REPOS_FILE= +AI_RUNS_FILE= +API_REQUEST_LOG_FILE= diff --git a/.gitignore b/.gitignore index f3d381b..bec566e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index aeaa528..5ed3f3c 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ Fast, analyst-friendly CVE search, GitHub repository monitoring, and automated v ## Overview -CVE Search turns raw vulnerability data into a workflow-oriented web app for research, prioritization, and lightweight tracking. +CVE Search turns raw vulnerability data into a workflow-oriented web app for research, prioritization, and operational tracking. -It combines URL-driven search, rich CVE detail pages, saved views, watchlists, alerts, triage state, and project grouping in a single interface. The app is designed to feel closer to an analyst workstation than a simple API browser. +It combines URL-driven search, rich CVE detail pages, saved views, watchlists, alerts, triage state, project workflows, team notifications, and a conversational workspace in a single interface. The app is designed to feel closer to an analyst workstation than a simple API browser. -It also includes an optional AI layer for natural-language search, analyst-facing CVE summaries, remediation guidance, and cross-workspace digests, with provider settings stored locally in the browser. +It also includes an optional AI layer for natural-language search, analyst-facing CVE summaries, remediation guidance, workspace digests, and workspace Q&A, with provider settings managed server-side through environment variables. -The GitHub Repos feature connects private and public repositories, scans their dependency trees for known vulnerabilities via the OSV.dev API, and can automatically generate fix PRs using AI. +The GitHub Repos feature connects private and public repositories, scans their dependency trees for known vulnerabilities via the OSV.dev API, persists scan history in SQLite, and can automatically generate fix PRs using AI. ## Screenshots @@ -46,10 +46,11 @@ Browser-local AI provider settings for choosing the provider, model, and API key - Severity filters and result sorting - Server-rendered homepage results - Rich CVE detail pages with EPSS, CWE, CAPEC, references, comments, and linked vulnerabilities when available -- Saved views, watchlist, alerts, and triage workflow -- Server-persisted projects workspace -- AI-assisted search, summaries, triage guidance, and workspace digests +- Saved views, watchlist, alerts, notifications, and triage workflow +- Server-persisted projects workspace with owner, due date, labels, status, SLA, and exception tracking +- AI-assisted search, summaries, triage guidance, workspace digests, and conversational workspace answers - GitHub repository monitoring with deep dependency scanning (npm, pnpm, Composer) across monorepo subdirectories +- Persisted repository scan history for monitored repos - AI-powered vulnerability fix generation with automatic pull request creation - Duplicate PR detection to avoid redundant fix branches - Export to CSV and JSON @@ -67,11 +68,12 @@ Browser-local AI provider settings for choosing the provider, model, and API key ### Analyst Workflow -- Save reusable searches as local saved views -- Bookmark CVEs and advisories in a local watchlist -- Create local alert rules and review matches in an alerts center -- Track local triage status, owner, tags, and notes -- Group CVEs into server-persisted projects stored in the workspace +- Save reusable searches as server-persisted saved views +- Bookmark CVEs and advisories in a server-persisted watchlist +- Create alert rules, review matches in an alerts center, and schedule digest delivery for team destinations +- Track triage status, owner, tags, and notes with approval checkpoints +- Group CVEs into server-persisted projects with owners, due dates, labels, status, timeline events, per-item assignment, SLA tracking, remediation state, and exceptions +- Ask the workspace assistant questions over watchlist, alerts, projects, and saved searches ### AI Workspace @@ -79,7 +81,8 @@ Browser-local AI provider settings for choosing the provider, model, and API key - Generate analyst-friendly CVE summaries and triage recommendations - Draft remediation notes from affected products, references, and available metadata - Build watchlist, alerts, and project digests from current workspace context -- Configure provider, model, and API key in a browser-local settings page +- Answer workspace questions over saved views, watchlist, alerts, and project workflow state +- Configure provider and model with server-side environment variables and per-feature overrides ### Vulnerability Detail @@ -96,6 +99,7 @@ Browser-local AI provider settings for choosing the provider, model, and API key - Deep dependency scanning using the GitHub Tree API to discover dependency files across all subdirectories - Supports npm (`package.json`, `package-lock.json`), pnpm (`pnpm-lock.yaml`), and Composer (`composer.json`, `composer.lock`) - Batch vulnerability lookup via the OSV.dev API with CVSS v3 base score calculation +- Persisted scan snapshots per monitored repository with historical visibility in the UI - Vulnerability detail links to internal CVE pages when a CVE alias is available - AI-powered fix generation: analyzes the vulnerability, generates file changes, creates a branch, commits, and opens a pull request - Heuristic fallback when no AI provider is configured (version bump to known fixed version) @@ -114,10 +118,10 @@ Browser-local AI provider settings for choosing the provider, model, and API key ## Current Boundaries - Vendor-only filtering is intentionally blocked because the current upstream flow is only trustworthy when vendor is paired with product. -- Saved views, watchlist, alerts, and triage state are browser-local, not synced across devices or users. -- AI provider settings and API keys are stored in browser local storage and are not encrypted. -- Projects and monitored repositories are persisted in the app workspace via JSON storage, not a production database. -- Team assignments, user accounts, email or Slack notifications, and scheduled reports are not implemented. +- Workspace data is scoped to the app session/user cookie rather than a shared organization identity system. +- AI providers are configured with server-side environment variables; there is no in-product credential management UI. +- Notification delivery currently persists in-app schedule and delivery records; external email/Slack/webhook delivery is modeled as destinations but not actually pushed to third-party services. +- GitHub monitoring requires a valid `GITHUB_TOKEN`; without one, the Repos workflow is limited to negative-path validation. - Lock file regeneration (`npm install`, `composer update`) must be run locally after merging AI-generated fix PRs. ## Quick Start @@ -150,13 +154,15 @@ Open `http://localhost:3000`. ### AI Configuration -To use model-backed AI features instead of the built-in heuristic fallback, open `/settings` in the app and configure: +To use model-backed AI features instead of the built-in heuristic fallback, configure server-side environment variables such as: -- provider (OpenAI or Anthropic) -- model -- API key +- `AI_PROVIDER` +- `OPENAI_API_KEY` +- `OPENAI_MODEL` +- `ANTHROPIC_API_KEY` +- `ANTHROPIC_MODEL` -AI settings are stored in browser `localStorage` and apply to search interpretation, CVE summaries, triage guidance, workspace digests, and vulnerability fix generation. +You can also override individual flows with feature-specific variables such as `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_PROJECT_SUMMARY_MODEL`, or `AI_DAILY_DIGEST_MODEL`. The `/settings` page shows the active configuration, prompt versions, tool registry, inventory assets, workspace import/export, and recent AI runs. ## Scripts @@ -176,7 +182,10 @@ The project includes lightweight TypeScript tests for: - prioritization and local alert matching - triage helpers - upstream response validation -- project helper logic +- project workflow logic +- repository scan persistence +- notification scheduling and digest delivery +- workspace assistant behavior - CVSS and description extraction GitHub Actions runs `lint`, `test`, and `build` on pushes and pull requests. @@ -198,16 +207,16 @@ src/ │ ├── cve/[id]/ # CVE detail route │ ├── projects/ # Projects route │ ├── repos/ # GitHub repository monitoring route -│ ├── settings/ # Browser-local AI provider settings +│ ├── settings/ # Server-side AI configuration, inventory, and workspace data +│ ├── workspace/ # Conversational workspace and notifications │ ├── watchlist/ # Watchlist route │ └── page.tsx # Homepage ├── components/ # Search, detail, workflow, repos, and navigation UI └── lib/ # Search logic, AI helpers, API clients, GitHub integration, - # dependency parsing, storage, validation, utilities + # dependency parsing, SQLite-backed storage, validation, utilities data/ -├── monitored-repos.json # Monitored repository persistence -└── projects.json # Workspace project persistence +└── app.db # Default SQLite workspace database tests/ # Node-based TypeScript test suite ``` @@ -255,6 +264,8 @@ Planning and benchmark docs live in [`docs/`](./docs): - `docs/improvement-plan.md` - `docs/execution-backlog.md` - `docs/opencve-benchmark.md` +- `docs/test-user-journeys.md` - concise QA checklist +- `docs/feature-validation-playbook.md` - full feature-by-feature validation guide - [`CHANGELOG.md`](./CHANGELOG.md) ## Tech Stack diff --git a/docs/ai-platform.md b/docs/ai-platform.md new file mode 100644 index 0000000..b65ef83 --- /dev/null +++ b/docs/ai-platform.md @@ -0,0 +1,22 @@ +# AI Platform Notes + +## Vercel AI SDK Evaluation + +Recommendation: do not adopt the Vercel AI SDK yet. + +Why: +- the current typed AI service already covers the app's immediate needs for provider routing, structured JSON generation, fallback handling, and run logging +- the largest remaining gaps are prompt/version management, reusable tool metadata, and regression coverage, which are provider-agnostic and are now implemented directly in the app +- introducing the SDK now would add another abstraction layer before the app has multiple real tool-executing agent loops in production + +When to revisit: +- when agent workflows start chaining multiple tool invocations in a single request +- when streaming partial tool/state updates becomes a product requirement +- when the project needs provider-specific structured outputs or tool-execution helpers beyond the current service layer + +## Current Direction + +- keep the existing typed AI service as the execution layer +- version prompts explicitly in code so behavior changes are reviewable +- define a small tool registry that future agents can share +- expand regression datasets before introducing a larger agent runtime dependency diff --git a/docs/feature-validation-playbook.md b/docs/feature-validation-playbook.md new file mode 100644 index 0000000..9e95a8c --- /dev/null +++ b/docs/feature-validation-playbook.md @@ -0,0 +1,1021 @@ +# Feature Validation Playbook + +## Scope + +This document is the deep validation companion to `docs/test-user-journeys.md`. It covers the full user-facing feature set currently exposed by the app. Each feature entry includes: + +- Entry point +- Preconditions +- User journey +- Expected result +- Verification status + +## Test Date + +- March 7, 2026 + +## Environment + +- Build verification: `npm run build` +- Test suite: `npm test` +- Lint: `npm run lint` +- Smoke environment: `npm start -- --hostname 127.0.0.1 --port 3001` + +## Verification Legend + +- `Smoke-tested`: exercised end to end against the local production server. +- `Automated`: covered by the automated test suite, but not re-run manually in the browser/API smoke pass for this document. +- `Partial`: some of the flow was exercised, but not every branch or UI state. +- `Environment-dependent`: requires external credentials or upstream services to fully validate. + +## Verification Summary + +- `npm test` passed: 65/65 +- `npm run build` passed +- `npm run lint` passed with one existing warning in `src/app/layout.tsx` (`@next/next/no-page-custom-font`) +- Route reachability was smoke-tested for `/`, `/watchlist`, `/alerts`, `/projects`, `/repos`, `/settings`, and `/workspace` +- Core CRUD and workflow APIs were smoke-tested for watchlist, triage, saved views, alerts, inventory, projects, notifications, workspace conversations, AI digest/search, and workspace export +- GitHub monitoring was only partially validated locally because a known-good GitHub repository/token combination was not available + +## Feature Inventory + +### Global Surfaces + +1. App route reachability +2. Header navigation between search, watchlist, alerts, projects, repos, settings, and workspace +3. Shared CVE card actions + +### Search Workspace (`/`) + +1. Keyword search +2. Structured filters +3. Natural-language AI search assistant +4. Search summary and active filter chips +5. Dashboard metrics panel +6. Saved views +7. Alert rule creation +8. AI digest panel +9. Result export +10. Pagination +11. CVE result list + +### CVE Detail (`/cve/[id]`) + +1. Bookmark to watchlist +2. Add to project +3. Copy link +4. AI insight panel +5. Triage workspace +6. AI triage assistant +7. Human approval checkpoint +8. AI remediation plan +9. AI exposure analysis +10. Aliases +11. CVSS score breakdown +12. EPSS enrichment +13. Affected products +14. Problem types / CWE +15. References +16. Linked vulnerabilities +17. CAPEC attack patterns +18. Comments +19. Raw JSON data + +### Watchlist (`/watchlist`) + +1. Watchlist summary cards +2. Status filtering +3. Bulk selection +4. Bulk remove +5. Bulk triage update +6. Bulk project assignment +7. Inline project creation for bulk assignment +8. AI watchlist review +9. AI digest panel +10. Selectable CVE list + +### Alerts (`/alerts`) + +1. Alert metrics +2. Rule-by-rule match evaluation +3. Unread match calculation +4. Mark single rule checked +5. Mark all checked +6. Delete rule +7. AI alert investigation +8. Matching CVE preview list + +### Projects (`/projects`) + +1. Create project +2. Delete project +3. Edit project metadata +4. Timeline view +5. Vulnerability workflow per project item +6. Update owner/remediation/SLA +7. Exception workflow +8. Remove project item +9. AI project summary +10. Embedded CVE previews + +### Repos (`/repos`) + +1. Browse GitHub repositories +2. Search GitHub repositories +3. Monitor repository +4. Unmonitor repository +5. Token scope warnings +6. Scan single repository +7. Scan all monitored repositories +8. Persisted scan history +9. Vulnerability result display +10. Fix workflow modal + +### Settings (`/settings`) + +1. Server-side AI configuration summary +2. Available provider badges +3. Sensitive-data redaction notice +4. Per-feature AI configuration cards +5. Prompt version catalog +6. Tool registry +7. Inventory mapping +8. Workspace export +9. Workspace import in merge mode +10. Workspace import in replace mode +11. Recent AI runs + +### Workspace (`/workspace`) + +1. Create conversation +2. Open conversation +3. Suggested questions +4. Ask workspace assistant +5. View assistant references +6. Create notification schedule +7. Run all due digests +8. Run a single schedule +9. Pause or enable a schedule +10. Delete a schedule +11. View delivery history + +## Detailed User Journeys + +### 1. Global Navigation and Reachability + +#### 1.1 Load every main route + +- Entry point: direct browser navigation +- Preconditions: app is running locally +- User journey: + 1. Open `/` + 2. Open `/watchlist` + 3. Open `/alerts` + 4. Open `/projects` + 5. Open `/repos` + 6. Open `/settings` + 7. Open `/workspace` +- Expected result: every route returns `200` and renders its primary page shell without crashing +- Verification: `Smoke-tested` + +#### 1.2 Navigate from page actions back to search + +- Entry point: any non-home route +- Preconditions: app is running +- User journey: + 1. Open a secondary page such as `/projects` + 2. Use the page-level `Back to Search` action +- Expected result: navigation returns to `/` +- Verification: `Partial` + +### 2. Search Workspace + +#### 2.1 Run a keyword search + +- Entry point: `/` +- Preconditions: none +- User journey: + 1. Enter a keyword such as `openssl` + 2. Submit the search +- Expected result: the URL search params update, the result list refreshes, and the summary reflects the active query +- Verification: `Automated` via `tests/search.test.ts` + +#### 2.2 Apply structured filters and sorting + +- Entry point: `/` +- Preconditions: homepage loaded +- User journey: + 1. Set vendor, product, or CWE filters + 2. Set a `since` date + 3. Change minimum severity + 4. Change sort order +- Expected result: result ordering and filtering update, and active filter chips appear above the result list +- Verification: `Automated` via `tests/search.test.ts` + +#### 2.3 Use the natural-language AI search assistant + +- Entry point: `/` +- Preconditions: homepage loaded +- User journey: + 1. Ask a natural-language question such as `show critical OpenSSL issues from the last 30 days` + 2. Apply the generated search parameters +- Expected result: the assistant translates the prompt into structured search state and the homepage reloads with those filters +- Verification: `Smoke-tested` through `POST /api/ai/search`, `Automated` via `tests/ai.test.ts` and `tests/ai-platform.test.ts` + +#### 2.4 Review dashboard metrics + +- Entry point: `/` +- Preconditions: homepage returns dashboard data +- User journey: + 1. Load the homepage with a normal search state + 2. Review the dashboard cards and summary panels +- Expected result: dashboard metrics render without blocking the search workflow +- Verification: `Partial` + +#### 2.5 Save the current search as a saved view + +- Entry point: `/` +- Preconditions: a non-trivial search is active +- User journey: + 1. Enter a saved-view name or accept the generated default + 2. Click `Save Current View` + 3. Open the saved view from the panel + 4. Delete the saved view +- Expected result: the search is persisted, reopening the view restores the search params, and deletion removes it from the list +- Verification: `Smoke-tested` via `POST /api/saved-views` and `DELETE /api/saved-views/[id]` + +#### 2.6 Save the current search as an alert rule + +- Entry point: `/` +- Preconditions: a useful search is active +- User journey: + 1. Enter an alert-rule name or accept the generated default + 2. Click `Save Alert` + 3. Open `/alerts` from the panel + 4. Delete the alert rule from the panel or Alerts page +- Expected result: the rule is stored, visible in the panel, and appears in the Alerts workspace +- Verification: `Smoke-tested` via `POST /api/alerts` and `DELETE /api/alerts/[id]`, `Automated` via `tests/alerts.test.ts` + +#### 2.7 Generate an AI digest from the search workspace + +- Entry point: `/` +- Preconditions: homepage loaded +- User journey: + 1. Open the digest panel + 2. Request a digest for the current workspace data +- Expected result: the digest summarizes current risks and recent activity +- Verification: `Smoke-tested` through `POST /api/ai/digest`, `Automated` via `tests/ai.test.ts` + +#### 2.8 Export visible search results + +- Entry point: `/` +- Preconditions: at least one search result is visible +- User journey: + 1. Click `Export CSV` + 2. Click `Export JSON` +- Expected result: downloads are generated with search-specific filenames and include the currently visible results +- Verification: `Partial` + +#### 2.9 Move through result pages + +- Entry point: `/` +- Preconditions: enough results exist to paginate +- User journey: + 1. Move to the next page + 2. Move back +- Expected result: the page number updates in the URL and the viewport scrolls to the top on page change +- Verification: `Automated` via `tests/search.test.ts` + +### 3. Search Result Cards + +#### 3.1 Review CVE card metadata + +- Entry point: any CVE list +- Preconditions: results available +- User journey: + 1. Open a result list + 2. Inspect severity, score, KEV/ransomware/EPSS badges, exploit reference count, products, assigner, and CWE metadata +- Expected result: cards show concise triage-relevant metadata without opening the detail page +- Verification: `Partial` + +#### 3.2 Bookmark a CVE from the card + +- Entry point: any CVE list +- Preconditions: result list loaded +- User journey: + 1. Click the bookmark control on a CVE card + 2. Open `/watchlist` +- Expected result: the CVE appears in the watchlist +- Verification: `Smoke-tested` via watchlist API flow + +#### 3.3 Add a CVE to a project from the card + +- Entry point: any CVE list +- Preconditions: projects may or may not already exist +- User journey: + 1. Open the `Projects` picker on a card + 2. Create a new project or choose an existing one + 3. Add the CVE +- Expected result: the project is created if needed and the CVE is attached to the selected project +- Verification: `Smoke-tested` via `POST /api/projects` and `POST /api/projects/[id]/items` + +#### 3.4 Copy a deep link to a CVE from the card + +- Entry point: any CVE list +- Preconditions: result list loaded +- User journey: + 1. Click the copy-link action + 2. Open the copied URL +- Expected result: the clipboard receives the `/cve/[id]` URL and the link opens the matching detail page +- Verification: `Partial` + +#### 3.5 Open the CVE detail page from the card + +- Entry point: any CVE list +- Preconditions: result list loaded +- User journey: + 1. Click the CVE title or footer link +- Expected result: the app opens `/cve/[id]` +- Verification: `Partial` + +### 4. CVE Detail Page + +#### 4.1 Load the CVE detail shell + +- Entry point: `/cve/[id]` +- Preconditions: valid CVE identifier +- User journey: + 1. Navigate directly to a known CVE detail page +- Expected result: summary metadata, action buttons, AI panels, triage panel, and supporting sections render together +- Verification: `Partial` + +#### 4.2 Bookmark, assign to project, and copy link from detail view + +- Entry point: `/cve/[id]` +- Preconditions: detail page loaded +- User journey: + 1. Bookmark the CVE + 2. Add it to an existing or newly created project + 3. Copy the deep link +- Expected result: the actions behave the same as the result-card controls +- Verification: `Smoke-tested` through the underlying watchlist/project APIs, `Partial` for clipboard UI + +#### 4.3 Update triage status, owner, tags, and notes + +- Entry point: `/cve/[id]` +- Preconditions: detail page loaded +- User journey: + 1. Change the triage status + 2. Enter an owner + 3. Enter comma-separated tags + 4. Add analyst notes +- Expected result: the record persists, `updatedAt` changes, and recent activity captures the edits +- Verification: `Smoke-tested` via `GET /api/triage/[id]` and `PUT /api/triage/[id]`, `Automated` via `tests/triage.test.ts` + +#### 4.4 Use the AI triage assistant with human approval + +- Entry point: `/cve/[id]` +- Preconditions: detail page loaded +- User journey: + 1. Ask the triage assistant to propose workflow changes + 2. Review the `Human Approval Checkpoint` + 3. Approve or cancel the proposed changes +- Expected result: AI-suggested edits are never silently applied; approval applies the proposed triage state and cancellation discards it +- Verification: `Automated` via `tests/ai.test.ts` and `tests/triage.test.ts` + +#### 4.5 Generate AI insight, remediation, and exposure analysis + +- Entry point: `/cve/[id]` +- Preconditions: detail page loaded +- User journey: + 1. Request the AI insight panel + 2. Request the remediation plan + 3. Request the exposure analysis +- Expected result: each panel returns focused output for understanding impact, mitigation, and likely environmental exposure +- Verification: `Automated` via `tests/ai.test.ts` and `tests/ai-platform.test.ts` + +#### 4.6 Review aliases, CVSS, and EPSS enrichment + +- Entry point: `/cve/[id]` +- Preconditions: CVE has alias or scoring data available +- User journey: + 1. Review aliases + 2. Review the CVSS score breakdown + 3. Review EPSS if available +- Expected result: normalized upstream data is displayed consistently even when the source shape varies +- Verification: `Automated` via `tests/validation.test.ts` + +#### 4.7 Review affected products, problem types, references, linked vulnerabilities, CAPEC, comments, and raw data + +- Entry point: `/cve/[id]` +- Preconditions: CVE detail loaded +- User journey: + 1. Review affected product entries + 2. Review CWE/problem-type information + 3. Open references + 4. Review linked vulnerabilities + 5. Review CAPEC attack patterns + 6. Review comments + 7. Expand raw JSON +- Expected result: the full investigation context is visible without leaving the detail page +- Verification: `Partial` + +### 5. Watchlist + +#### 5.1 Add and remove a watchlist item + +- Entry point: any result card or detail page, then `/watchlist` +- Preconditions: known CVE ID +- User journey: + 1. Add a CVE to the watchlist + 2. Load `/watchlist` + 3. Remove the CVE +- Expected result: the watchlist shows the CVE when added and no longer shows it after removal +- Verification: `Smoke-tested` via `GET /api/watchlist`, `POST /api/watchlist`, and `DELETE /api/watchlist` + +#### 5.2 Filter watchlist by triage status + +- Entry point: `/watchlist` +- Preconditions: watchlist contains items across multiple statuses +- User journey: + 1. Click `Total`, `New`, `Investigating`, `Mitigated`, `Accepted`, and `Closed` +- Expected result: the list filters by the selected status and the summary cards remain in sync with triage state +- Verification: `Partial` + +#### 5.3 Select visible items and clear selection + +- Entry point: `/watchlist` +- Preconditions: at least one watchlist item exists +- User journey: + 1. Click `Select Visible` + 2. Confirm item selection counts + 3. Click `Clear Selection` +- Expected result: bulk-selection state is accurately reflected across the current filtered list +- Verification: `Partial` + +#### 5.4 Bulk remove selected CVEs + +- Entry point: `/watchlist` +- Preconditions: one or more items selected +- User journey: + 1. Select multiple watchlist items + 2. Click `Remove Selected` +- Expected result: all selected items are removed and success feedback shows the count +- Verification: `Smoke-tested` through watchlist delete flow + +#### 5.5 Bulk update triage status + +- Entry point: `/watchlist` +- Preconditions: one or more items selected +- User journey: + 1. Select a target triage status + 2. Click `Apply` +- Expected result: triage records update for all selected CVEs +- Verification: `Smoke-tested` through the triage API flow + +#### 5.6 Bulk assign selected CVEs to an existing project + +- Entry point: `/watchlist` +- Preconditions: one or more items selected and at least one project exists +- User journey: + 1. Choose a project + 2. Click `Add to Project` +- Expected result: every selected CVE is attached to the chosen project +- Verification: `Smoke-tested` via `POST /api/projects/[id]/items` + +#### 5.7 Create a project inline during bulk assignment + +- Entry point: `/watchlist` +- Preconditions: one or more items selected +- User journey: + 1. Choose `Create new project...` + 2. Enter a project name + 3. Click `Add to Project` +- Expected result: the new project is created and the selected CVEs are added to it +- Verification: `Smoke-tested` via `POST /api/projects` and `POST /api/projects/[id]/items` + +#### 5.8 Generate an AI watchlist review + +- Entry point: `/watchlist` +- Preconditions: watchlist has content +- User journey: + 1. Open the AI watchlist review panel + 2. Request a review +- Expected result: the panel summarizes the most important watchlist items and likely priorities +- Verification: `Automated` via `tests/ai.test.ts` and `tests/ai-platform.test.ts` + +#### 5.9 Generate a digest from the watchlist page + +- Entry point: `/watchlist` +- Preconditions: watchlist has content +- User journey: + 1. Open the digest panel + 2. Request a digest +- Expected result: digest generation works from the watchlist context as well as the homepage +- Verification: `Smoke-tested` through `POST /api/ai/digest` + +### 6. Alerts + +#### 6.1 Review alert metrics and evaluated rules + +- Entry point: `/alerts` +- Preconditions: at least one alert rule exists +- User journey: + 1. Open `/alerts` + 2. Review alert-rule count, unread match count, and sampled CVE count + 3. Review the rule sections and their current matches +- Expected result: each rule shows matching CVEs against the sampled latest dataset +- Verification: `Partial` + +#### 6.2 Mark a single alert rule checked + +- Entry point: `/alerts` +- Preconditions: at least one alert rule exists +- User journey: + 1. Click `Mark Checked` on a rule +- Expected result: `lastCheckedAt` updates and unread calculations recalculate accordingly +- Verification: `Smoke-tested` via `PATCH /api/alerts/[id]`, `Automated` via `tests/alerts.test.ts` + +#### 6.3 Mark all alert rules checked + +- Entry point: `/alerts` +- Preconditions: at least one alert rule exists +- User journey: + 1. Click `Mark All Checked` +- Expected result: all rules receive a new `lastCheckedAt` +- Verification: `Smoke-tested` via `POST /api/alerts/mark-all`, `Automated` via `tests/alerts.test.ts` + +#### 6.4 Delete an alert rule + +- Entry point: `/alerts` +- Preconditions: at least one alert rule exists +- User journey: + 1. Click `Delete` on a rule +- Expected result: the rule disappears from the page and from local workspace storage +- Verification: `Smoke-tested` via `DELETE /api/alerts/[id]` + +#### 6.5 Run the AI alert investigation panel + +- Entry point: `/alerts` +- Preconditions: at least one alert rule exists +- User journey: + 1. Expand or trigger the AI investigation for a rule +- Expected result: the assistant summarizes what changed, what matters, and how to triage the matching set +- Verification: `Automated` via `tests/ai.test.ts` and `tests/ai-platform.test.ts` + +### 7. Projects + +#### 7.1 Create a project from the Projects page + +- Entry point: `/projects` +- Preconditions: none +- User journey: + 1. Enter a new project name + 2. Click `Create Project` +- Expected result: a new project card appears immediately +- Verification: `Smoke-tested` via `POST /api/projects`, `Automated` via `tests/projects-store.test.ts` + +#### 7.2 Update project metadata + +- Entry point: `/projects` +- Preconditions: at least one project exists +- User journey: + 1. Edit name + 2. Edit owner + 3. Set due date + 4. Change project status + 5. Edit labels + 6. Edit description + 7. Click `Save project` +- Expected result: the project persists with the new metadata and the card summary reflects the changes +- Verification: `Smoke-tested` via `PATCH /api/projects/[id]`, `Automated` via `tests/projects-store.test.ts` + +#### 7.3 Review project timeline visibility + +- Entry point: `/projects` +- Preconditions: project has prior activity +- User journey: + 1. Open a populated project + 2. Review the `Timeline view` +- Expected result: recent project events appear with timestamped summaries and event-kind badges +- Verification: `Partial` + +#### 7.4 Add a CVE to a project from other surfaces + +- Entry point: search results, watchlist, or detail page +- Preconditions: target CVE exists +- User journey: + 1. Add a CVE to a project using a project picker or bulk assignment + 2. Open `/projects` +- Expected result: the project contains the new project item and can preview the linked CVE +- Verification: `Smoke-tested` via `POST /api/projects/[id]/items` + +#### 7.5 Update project-item workflow fields + +- Entry point: `/projects` +- Preconditions: a project contains at least one CVE +- User journey: + 1. Set workflow owner + 2. Change remediation state + 3. Set an SLA due date + 4. Add workflow notes + 5. Click `Save workflow` +- Expected result: the item persists with the new workflow state and summary counters update +- Verification: `Smoke-tested` via `PATCH /api/projects/[id]/items`, `Automated` via `tests/projects-store.test.ts` + +#### 7.6 Manage a project exception + +- Entry point: `/projects` +- Preconditions: a project item exists +- User journey: + 1. Enter exception reason + 2. Enter approver + 3. Set exception expiry + 4. Add exception notes + 5. Save workflow +- Expected result: the item is marked as an exception-backed workflow state and exception metadata is persisted +- Verification: `Smoke-tested` via `PATCH /api/projects/[id]/items`, `Automated` via `tests/projects-store.test.ts` + +#### 7.7 Remove a CVE from a project + +- Entry point: `/projects` +- Preconditions: a project contains at least one item +- User journey: + 1. Click `Remove` on a workflow item +- Expected result: the CVE is removed from the project and no longer appears in the embedded preview +- Verification: `Smoke-tested` via `DELETE /api/projects/[id]/items` + +#### 7.8 Delete a project + +- Entry point: `/projects` +- Preconditions: at least one project exists +- User journey: + 1. Click `Delete Project` +- Expected result: the project is removed from storage and from any project pickers +- Verification: `Smoke-tested` via `DELETE /api/projects/[id]` + +#### 7.9 Generate an AI project summary + +- Entry point: `/projects` +- Preconditions: a project with meaningful content exists +- User journey: + 1. Request a project summary from the AI panel +- Expected result: the summary highlights scope, SLA risk, progress, and exceptions +- Verification: `Automated` via `tests/ai.test.ts` and `tests/ai-platform.test.ts` + +### 8. Repos + +#### 8.1 Open the GitHub repo browser + +- Entry point: `/repos` +- Preconditions: GitHub integration is configured +- User journey: + 1. Click `Browse GitHub Repositories` +- Expected result: the GitHub repo list loads and the browser panel opens +- Verification: `Partial`, `Environment-dependent` + +#### 8.2 Filter the GitHub repository list + +- Entry point: `/repos` +- Preconditions: repo browser is open and contains results +- User journey: + 1. Enter text in `Search repositories...` +- Expected result: the repo list narrows to matching `full_name` values +- Verification: `Partial`, `Environment-dependent` + +#### 8.3 Monitor and unmonitor repositories + +- Entry point: `/repos` +- Preconditions: GitHub repo browser loaded +- User journey: + 1. Click `Monitor` for a repository + 2. Confirm the repo appears in the monitored list + 3. Click `Remove` +- Expected result: the repo moves into and out of the monitored workspace set +- Verification: `Partial` for UI, `Automated` via `tests/monitored-repos-store.test.ts` and `tests/github.test.ts` + +#### 8.4 Surface token-scope guidance + +- Entry point: `/repos` +- Preconditions: GitHub token lacks required access or returns only a subset of repos +- User journey: + 1. Open the GitHub repo browser with a limited token +- Expected result: scope warnings explain missing `repo` access or missing fine-grained repo grants +- Verification: `Partial`, `Environment-dependent` + +#### 8.5 Load monitored repositories + +- Entry point: `/repos` +- Preconditions: monitored repos exist +- User journey: + 1. Open `/repos` +- Expected result: monitored repositories load immediately and existing scan history is fetched for each one +- Verification: `Smoke-tested` via `GET /api/github/monitored` + +#### 8.6 Scan a single monitored repository + +- Entry point: `/repos` +- Preconditions: monitored repository exists and GitHub API access works +- User journey: + 1. Click `Scan` on a monitored repo +- Expected result: the repo enters a scanning state, then shows vulnerabilities or a surfaced scan error +- Verification: `Partial`, `Environment-dependent` + +#### 8.7 Scan all monitored repositories + +- Entry point: `/repos` +- Preconditions: at least one monitored repo exists +- User journey: + 1. Click `Scan All` +- Expected result: all monitored repos begin scanning concurrently and settle individually +- Verification: `Partial`, `Environment-dependent` + +#### 8.8 Review persisted scan history + +- Entry point: `/repos` +- Preconditions: at least one repo has been scanned previously +- User journey: + 1. Open the monitored repo card + 2. Review scan-history entries +- Expected result: prior scans are listed from persisted SQLite-backed history +- Verification: `Automated` via `tests/repo-scans-store.test.ts` + +#### 8.9 Review repository vulnerability rows + +- Entry point: `/repos` +- Preconditions: a successful scan exists +- User journey: + 1. Expand scan results + 2. Review affected package, severity, advisory, and fix metadata +- Expected result: vulnerabilities are grouped under the scanned repository with severity context +- Verification: `Automated` via `tests/dependency-parser.test.ts` and `tests/github.test.ts` + +#### 8.10 Open the fix workflow modal + +- Entry point: `/repos` +- Preconditions: successful scan results include actionable vulnerabilities +- User journey: + 1. Open the vulnerability fix modal from a finding + 2. Review proposed fix options and PR flow inputs +- Expected result: the modal prepares a fix workflow for GitHub PR creation +- Verification: `Automated` via `tests/github-pr.test.ts`, `Environment-dependent` for live PR creation + +#### 8.11 Negative-path repo scanning + +- Entry point: `/repos` +- Preconditions: local environment without a known-good upstream target +- User journey: + 1. Submit a scan request for a monitored repository +- Expected result: the app surfaces the upstream failure instead of crashing +- Verification: `Smoke-tested`; observed `POST /api/github/scan` returned `502` with `GitHub API error: Not Found` + +### 9. Settings + +#### 9.1 Review the server-side AI configuration summary + +- Entry point: `/settings` +- Preconditions: settings page loaded +- User journey: + 1. Open the page + 2. Review provider, mode, and model summary cards +- Expected result: the user can see whether AI is running in configured-provider or heuristic mode +- Verification: `Partial` + +#### 9.2 Review provider availability and redaction posture + +- Entry point: `/settings` +- Preconditions: settings page loaded +- User journey: + 1. Review available provider badges + 2. Review the redaction safeguard callout +- Expected result: the page clearly communicates whether sensitive metadata is redacted before third-party model calls +- Verification: `Partial` + +#### 9.3 Review per-feature AI configuration + +- Entry point: `/settings` +- Preconditions: settings page loaded +- User journey: + 1. Review each feature card in `Per-Feature Configuration` +- Expected result: every AI feature shows its provider, model, and configured versus heuristic mode +- Verification: `Partial` + +#### 9.4 Review prompt versions and tool registry + +- Entry point: `/settings` +- Preconditions: settings page loaded +- User journey: + 1. Review `Prompt Versions` + 2. Review `Tool Registry` +- Expected result: prompt-template versions and shared tool capabilities are visible for auditability +- Verification: `Partial` + +#### 9.5 Add an inventory asset + +- Entry point: `/settings` +- Preconditions: none +- User journey: + 1. Enter asset name + 2. Enter vendor and/or product + 3. Optionally enter version, environment, criticality, and notes + 4. Click `Add Asset` +- Expected result: the asset appears in `Tracked Assets` and inventory counts update +- Verification: `Smoke-tested` via `POST /api/inventory`, `Automated` via related workspace-store coverage + +#### 9.6 Remove an inventory asset + +- Entry point: `/settings` +- Preconditions: at least one asset exists +- User journey: + 1. Click `Delete` on an asset +- Expected result: the asset is removed from the tracked list +- Verification: `Smoke-tested` via `DELETE /api/inventory/[id]` + +#### 9.7 Export workspace data + +- Entry point: `/settings` +- Preconditions: workspace has data +- User journey: + 1. Click `Export JSON` +- Expected result: a JSON snapshot downloads containing watchlist, saved views, prompt templates, alert rules, inventory, triage, and projects +- Verification: `Smoke-tested` via `GET /api/workspace/export`, `Automated` via `tests/workspace-store.test.ts` + +#### 9.8 Import workspace data in merge mode + +- Entry point: `/settings` +- Preconditions: valid workspace export file available +- User journey: + 1. Choose `Merge Import` + 2. Select a workspace snapshot JSON +- Expected result: imported records are upserted without clearing existing workspace data +- Verification: `Automated` via `tests/workspace-store.test.ts` + +#### 9.9 Import workspace data in replace mode + +- Entry point: `/settings` +- Preconditions: valid workspace export file available +- User journey: + 1. Choose `Replace Existing` + 2. Select a workspace snapshot JSON +- Expected result: current workspace data is cleared and then replaced by the imported snapshot +- Verification: `Automated` via `tests/workspace-store.test.ts` + +#### 9.10 Review recent AI runs + +- Entry point: `/settings` +- Preconditions: AI-enabled flows have been exercised +- User journey: + 1. Open `Recent AI Runs` + 2. Review status, provider, timestamps, latency, token estimate, cost estimate, prompt, output, tool calls, and errors +- Expected result: the page acts as a read-only operational audit trail for AI activity +- Verification: `Automated` via `tests/ai-runs-store.test.ts` and `tests/ai-platform.test.ts` + +### 10. Workspace + +#### 10.1 Create a new empty conversation + +- Entry point: `/workspace` +- Preconditions: workspace page loaded +- User journey: + 1. Click `New Conversation` +- Expected result: a new conversation record is created and becomes active +- Verification: `Smoke-tested` via `POST /api/workspace/conversations`, `Automated` via `tests/workspace-assistant.test.ts` + +#### 10.2 Start a conversation from a prompt + +- Entry point: `/workspace` +- Preconditions: workspace page loaded +- User journey: + 1. Ask `Give me a workspace overview` +- Expected result: a new conversation is created with stored user and assistant messages +- Verification: `Smoke-tested` via `POST /api/workspace/conversations` + +#### 10.3 Append a follow-up question to an existing conversation + +- Entry point: `/workspace` +- Preconditions: a conversation exists +- User journey: + 1. Ask `Show project SLA risk` +- Expected result: the assistant appends a new turn to the active conversation and preserves thread continuity +- Verification: `Smoke-tested` via `POST /api/workspace/conversations/[id]/messages`, `Automated` via `tests/workspace-assistant.test.ts` + +#### 10.4 Use suggested questions + +- Entry point: `/workspace` +- Preconditions: workspace page loaded +- User journey: + 1. Click one of the suggested prompts +- Expected result: the assistant submits the prompt immediately and returns a response +- Verification: `Partial` + +#### 10.5 Open historical conversations + +- Entry point: `/workspace` +- Preconditions: multiple conversations exist +- User journey: + 1. Select a prior conversation from the conversation list +- Expected result: the full stored conversation reloads into the active panel +- Verification: `Partial`, `Automated` via `tests/workspace-assistant.test.ts` + +#### 10.6 Review assistant references + +- Entry point: `/workspace` +- Preconditions: assistant response contains references +- User journey: + 1. Inspect assistant messages + 2. Review attached reference chips +- Expected result: referenced workspace entities are shown alongside the response +- Verification: `Smoke-tested` through workspace conversation flow + +#### 10.7 Create a notification schedule + +- Entry point: `/workspace` +- Preconditions: workspace page loaded +- User journey: + 1. Enter team name + 2. Choose channel and destination + 3. Choose cadence + 4. Save the schedule +- Expected result: the schedule appears in the preference list +- Verification: `Smoke-tested` via `GET /api/notifications` and `POST /api/notifications`, `Automated` via `tests/notifications-store.test.ts` + +#### 10.8 Run all due digests + +- Entry point: `/workspace` +- Preconditions: at least one schedule exists +- User journey: + 1. Click the page-level run action +- Expected result: due schedules produce delivery records and success feedback +- Verification: `Smoke-tested` via `POST /api/notifications/run` + +#### 10.9 Run a single schedule + +- Entry point: `/workspace` +- Preconditions: at least one schedule exists +- User journey: + 1. Trigger digest delivery for a single schedule +- Expected result: only the selected schedule produces a delivery entry +- Verification: `Partial`, `Automated` via `tests/notifications-store.test.ts` + +#### 10.10 Pause and re-enable a schedule + +- Entry point: `/workspace` +- Preconditions: at least one schedule exists +- User journey: + 1. Toggle a schedule off + 2. Toggle it back on +- Expected result: the persisted `enabled` state changes without breaking the schedule definition +- Verification: `Smoke-tested` via `PATCH /api/notifications/[id]`, `Automated` via `tests/notifications-store.test.ts` + +#### 10.11 Delete a schedule + +- Entry point: `/workspace` +- Preconditions: at least one schedule exists +- User journey: + 1. Delete a schedule +- Expected result: the schedule disappears while historical deliveries remain available if stored separately +- Verification: `Smoke-tested` via `DELETE /api/notifications/[id]` + +#### 10.12 Review delivery history + +- Entry point: `/workspace` +- Preconditions: at least one digest has been run +- User journey: + 1. Review the delivery-history list +- Expected result: digest deliveries show timestamps and delivery context +- Verification: `Partial`, `Automated` via `tests/notifications-store.test.ts` + +## Automated Coverage Map + +- Search, filtering, and result preference logic: `tests/search.test.ts` +- Triage persistence and workflow behavior: `tests/triage.test.ts` +- Alert rule persistence and evaluation behavior: `tests/alerts.test.ts` +- Project storage, workflow updates, timelines, and exceptions: `tests/projects-store.test.ts` +- Repo monitoring and GitHub integration helpers: `tests/github.test.ts`, `tests/monitored-repos-store.test.ts` +- Repo scan persistence: `tests/repo-scans-store.test.ts` +- Fix PR workflow logic: `tests/github-pr.test.ts` +- Dependency parsing for repo scans: `tests/dependency-parser.test.ts` +- AI provider behavior and heuristic fallbacks: `tests/ai.test.ts`, `tests/ai-platform.test.ts` +- AI run history persistence: `tests/ai-runs-store.test.ts` +- Workspace assistant persistence and response behavior: `tests/workspace-assistant.test.ts` +- Workspace import/export store behavior: `tests/workspace-store.test.ts` +- Validation and upstream normalization: `tests/validation.test.ts` +- General utility coverage: `tests/utils.test.ts` +- API route guards: `tests/api-route-guard.test.ts` + +## Environment Caveats + +### GitHub Scanning and Fix PR Creation + +- Live repository browsing, dependency scanning, and fix PR creation depend on valid GitHub credentials and repository access. +- In the March 7, 2026 local verification run, monitored-repo loading worked, but a live scan request returned `502` with `GitHub API error: Not Found`. +- The negative path was therefore validated, but a successful end-to-end scan plus PR flow still needs a fully provisioned GitHub test environment. + +### Browser-Only Interactions + +- Clipboard, download, and some visual-state interactions were validated primarily through UI inspection and underlying API behavior rather than automated browser playback. +- If stricter regression protection is needed, the next best step is to add Playwright journeys for the highest-value flows: + 1. Search to watchlist to triage + 2. Search to project workflow + 3. Workspace notifications and conversation history + 4. Repo scan happy-path with mocked GitHub responses diff --git a/docs/test-user-journeys.md b/docs/test-user-journeys.md new file mode 100644 index 0000000..330ca7f --- /dev/null +++ b/docs/test-user-journeys.md @@ -0,0 +1,152 @@ +# Test User Journeys + +This is the concise QA checklist for routine validation runs. For the exhaustive feature-by-feature version, use `docs/feature-validation-playbook.md`. + +## Verification Snapshot + +- Verification baseline date: March 7, 2026 +- Build: `npm run build` passed +- Tests: `npm test` passed with 65/65 +- Lint: `npm run lint` passed with one existing warning in `src/app/layout.tsx` +- Local smoke target: `npm start -- --hostname 127.0.0.1 --port 3001` + +## Status Legend + +- `Smoke-tested`: verified against the local production server +- `Automated`: covered by the automated suite +- `Partial`: only some UI states or branches were manually exercised +- `Environment-dependent`: requires external credentials or upstream systems + +## Recommended QA Run Order + +1. Route reachability +2. Search and result actions +3. Watchlist and triage +4. Alerts +5. Projects +6. Workspace conversations and notifications +7. Settings data workflows +8. Repos and GitHub flows + +## Core Journeys + +### 1. Route Reachability + +- Open `/`, `/watchlist`, `/alerts`, `/projects`, `/repos`, `/settings`, and `/workspace` +- Expected result: each route returns `200` and renders its page shell +- Verification: `Smoke-tested` + +### 2. Search Workspace + +- Run a keyword search such as `openssl` +- Apply structured filters for vendor, product, CWE, `since`, severity, and sort +- Use the AI search assistant and apply the generated search state +- Save the current search as a saved view, reopen it, then delete it +- Save the current search as an alert rule +- Generate an AI digest +- Export visible results as CSV and JSON +- Expected result: URL state, filters, saved views, alerts, digests, and exports all behave consistently +- Verification: `Smoke-tested` for saved views, alerts, and AI search; `Automated` for search/filter logic; `Partial` for download UX + +### 3. CVE Result Actions + +- From a CVE card, bookmark the CVE +- Add the CVE to a project +- Copy the deep link +- Open the detail page +- Expected result: result cards support direct workflow entry without leaving the list context +- Verification: `Smoke-tested` for watchlist and project APIs; `Partial` for clipboard UX + +### 4. CVE Detail Workflow + +- Open `/cve/[id]` +- Update triage status, owner, tags, and notes +- Use the AI triage assistant and review the human approval checkpoint +- Generate AI insight, remediation, and exposure output +- Review aliases, CVSS, EPSS, products, references, linked vulnerabilities, CAPEC, comments, and raw data +- Expected result: the detail page acts as the primary investigation and workflow surface +- Verification: `Smoke-tested` for triage persistence; `Automated` for AI and normalization logic; `Partial` for full visual inspection + +### 5. Watchlist + +- Add a CVE to the watchlist and load `/watchlist` +- Filter by triage status +- Select visible items and clear selection +- Bulk remove selected items +- Bulk update triage status +- Bulk assign items to an existing project +- Create a new project inline during assignment +- Run the AI watchlist review and page-level digest +- Expected result: watchlist supports individual and bulk vulnerability operations +- Verification: `Smoke-tested` for CRUD and project assignment; `Automated` for AI helpers; `Partial` for all selection states + +### 6. Alerts + +- Open `/alerts` +- Review metrics, unread counts, and rule sections +- Mark one rule checked +- Mark all rules checked +- Delete a rule +- Run the AI alert investigation panel +- Expected result: alert rules are reviewable, mutable, and AI-explainable +- Verification: `Smoke-tested` for rule mutations; `Automated` for alert logic and AI helpers; `Partial` for full UI inspection + +### 7. Projects + +- Create a project +- Update project metadata: owner, due date, labels, status, description +- Add a CVE to the project from another surface +- Update project-item owner, remediation state, SLA due date, notes, and exception fields +- Remove a project item +- Delete a project +- Run the AI project summary +- Expected result: projects support end-to-end remediation tracking and exception handling +- Verification: `Smoke-tested` for project and item CRUD; `Automated` for store logic and AI helpers + +### 8. Workspace + +- Create a new conversation +- Ask `Give me a workspace overview` +- Ask a follow-up such as `Show project SLA risk` +- Open a historical conversation +- Review assistant reference chips +- Create a notification schedule +- Run all due digests +- Toggle a schedule off and on +- Delete a schedule +- Review delivery history +- Expected result: the workspace acts as both a conversational analyst surface and a digest orchestration center +- Verification: `Smoke-tested` for conversations and notification CRUD/run flows; `Automated` for assistant and notification stores; `Partial` for all UI states + +### 9. Settings + +- Review AI provider summary, redaction posture, per-feature configuration, prompt versions, and tool registry +- Add and delete an inventory asset +- Export workspace data +- Import workspace data in merge mode +- Import workspace data in replace mode +- Review recent AI runs +- Expected result: the settings page exposes AI operations visibility and workspace data management +- Verification: `Smoke-tested` for inventory CRUD and export; `Automated` for workspace import/export and AI run persistence; `Partial` for read-only operational panels + +### 10. Repos + +- Open `/repos` +- Load monitored repositories +- Open the GitHub repo browser +- Search accessible repositories +- Monitor and unmonitor a repository +- Review token-scope warnings if shown +- Attempt a single scan and, where possible, `Scan All` +- Review scan history and vulnerability rows +- Open the fix modal if a successful scan is available +- Expected result: repo monitoring works end to end when GitHub credentials and target repositories are valid +- Verification: `Smoke-tested` for monitored-repo loading and negative-path scan handling; `Automated` for scan persistence, parsing, and PR workflow logic; `Environment-dependent` for live scan and PR creation + +## Known Environment Caveat + +- In the March 7, 2026 local verification run, `POST /api/github/scan` returned `502` with `GitHub API error: Not Found`, so the failure path was validated but the happy-path GitHub scan plus PR flow still needs a provisioned external test target. + +## Companion Doc + +- Deep validation guide: `docs/feature-validation-playbook.md` diff --git a/docs/todo.md b/docs/todo.md index 26efd91..15452e7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,57 +1,68 @@ # Todo +## misc stuff + +- [x] make the UI use 95% of the screen width +- [x] persist scan results in "repos" module in db. + + ## Recommended Build Order ### Build First -- replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) -- add authentication and authorization -- move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence -- stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage -- replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows -- evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js -- define a small tool registry for agent workflows -- add prompt and version management so changes to agent behavior are explicit and reversible -- add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality -- add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses -- upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches -- add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions -- add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action -- add search explanation output that shows exactly which fields, filters, and assumptions the AI applied -- improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata -- add stronger schema validation around upstream CIRCL payloads and AI-generated JSON +- [x] replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) +- [x] add authentication and authorization +- [x] move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence +- [x] harden GitHub repository monitoring routes with the same rate limiting and request logging used by the rest of the API +- [x] make GitHub dependency scans branch-accurate and fail closed on tree truncation or dependency file fetch errors +- [x] preserve dependency manifest location through scan and fix flows so monorepo remediation targets the correct workspace +- [x] constrain AI-generated fix PR file writes to server-validated repository files only +- [x] add regression tests for dependency parsing and GitHub scan edge cases +- [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage +- [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows +- [x] evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js +- [x] define a small tool registry for agent workflows +- [x] add prompt and version management so changes to agent behavior are explicit and reversible +- [x] add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality +- [x] add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses +- [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches +- [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions +- [x] add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action +- [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied +- [x] improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata +- [x] add stronger schema validation around upstream CIRCL payloads and AI-generated JSON ### Build Next -- persist AI runs, prompts, outputs, tool calls, and failures for debugging and review -- add per-feature model/provider configuration instead of one shared global setting for every AI flow -- add audit fields and activity history for project and triage changes -- add rate limiting and request logging for API routes -- enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals -- improve result cards with stronger severity, EPSS, KEV, and recency cues -- add bulk actions for watchlist, triage, and project assignment -- add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability -- add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review -- add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats -- add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view -- add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications -- add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default +- [x] persist AI runs, prompts, outputs, tool calls, and failures for debugging and review +- [x] add per-feature model/provider configuration instead of one shared global setting for every AI flow +- [x] add audit fields and activity history for project and triage changes +- [x] add rate limiting and request logging for API routes +- [x] enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals +- [x] improve result cards with stronger severity, EPSS, KEV, and recency cues +- [x] add bulk actions for watchlist, triage, and project assignment +- [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability +- [x] add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review +- [x] add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats +- [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view +- [x] add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications +- [x] add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default ### Build Later -- integrate Radix UI primitives/theme for a more consistent UI system -- add richer dashboard views for analysts, maintainers, and incident response workflows -- add better empty states, skeleton states, and success/error feedback across the app -- add import/export for projects, triage state, saved views, and watchlists -- expand project management with owners, due dates, labels, status, and timeline views -- add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions -- add asset or product inventory mapping so CVEs can be linked to affected internal systems -- add team-facing notifications and scheduled digest delivery -- add usage tracking, latency metrics, and cost visibility for each AI feature -- expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent -- add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" -- add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact -- add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches +- [x] integrate Radix UI primitives/theme for a more consistent UI system +- [x] add richer dashboard views for analysts, maintainers, and incident response workflows +- [x] add better empty states, skeleton states, and success/error feedback across the app +- [x] add import/export for projects, triage state, saved views, and watchlists +- [x] expand project management with owners, due dates, labels, status, and timeline views +- [x] add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions +- [x] add asset or product inventory mapping so CVEs can be linked to affected internal systems +- [x] add team-facing notifications and scheduled digest delivery +- [x] add usage tracking, latency metrics, and cost visibility for each AI feature +- [x] expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent +- [x] add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" +- [x] add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact +- [x] add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches ### Suggested First Slice @@ -63,69 +74,72 @@ ## Foundation -- replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) -- add authentication and authorization -- move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence -- add audit fields and activity history for project and triage changes -- add rate limiting and request logging for API routes +- [x] replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) +- [x] add authentication and authorization +- [x] move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence +- [x] harden GitHub repository monitoring routes with the same rate limiting and request logging used by the rest of the API +- [x] add audit fields and activity history for project and triage changes +- [x] add rate limiting and request logging for API routes ## Product and UX -- integrate Radix UI primitives/theme for a more consistent UI system -- add richer dashboard views for analysts, maintainers, and incident response workflows -- improve result cards with stronger severity, EPSS, KEV, and recency cues -- add better empty states, skeleton states, and success/error feedback across the app -- add bulk actions for watchlist, triage, and project assignment -- add import/export for projects, triage state, saved views, and watchlists +- [x] integrate Radix UI primitives/theme for a more consistent UI system +- [x] add richer dashboard views for analysts, maintainers, and incident response workflows +- [x] improve result cards with stronger severity, EPSS, KEV, and recency cues +- [x] add better empty states, skeleton states, and success/error feedback across the app +- [x] add bulk actions for watchlist, triage, and project assignment +- [x] add import/export for projects, triage state, saved views, and watchlists ## Vulnerability Management -- expand project management with owners, due dates, labels, status, and timeline views -- add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions -- add asset or product inventory mapping so CVEs can be linked to affected internal systems -- enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals -- add team-facing notifications and scheduled digest delivery +- [x] expand project management with owners, due dates, labels, status, and timeline views +- [x] add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions +- [x] add asset or product inventory mapping so CVEs can be linked to affected internal systems +- [x] enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals +- [x] add team-facing notifications and scheduled digest delivery ## AI and Agent Platform -- replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows -- evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js -- define a small tool registry for agent workflows: +- [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows +- [x] evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js +- [x] define a small tool registry for agent workflows: - search CVEs - fetch CVE details - read watchlist state - read alert rule matches - read and update project records - read and update triage state -- persist AI runs, prompts, outputs, tool calls, and failures for debugging and review -- add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality -- add per-feature model/provider configuration instead of one shared global setting for every AI flow +- [x] persist AI runs, prompts, outputs, tool calls, and failures for debugging and review +- [x] add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality +- [x] add per-feature model/provider configuration instead of one shared global setting for every AI flow ## AI Features to Add -- upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches -- add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions -- add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability -- add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review -- add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats -- add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action -- add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view -- add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact -- add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches -- add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications +- [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches +- [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions +- [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability +- [x] add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review +- [x] add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats +- [x] add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action +- [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view +- [x] add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact +- [x] add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches +- [x] add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications ## AI Safety and Operations -- stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage -- add usage tracking, latency metrics, and cost visibility for each AI feature -- add prompt and version management so changes to agent behavior are explicit and reversible -- add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses -- add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default +- [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage +- [x] add usage tracking, latency metrics, and cost visibility for each AI feature +- [x] add prompt and version management so changes to agent behavior are explicit and reversible +- [x] add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses +- [x] add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default ## Search and Data Quality -- expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent -- add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" -- add search explanation output that shows exactly which fields, filters, and assumptions the AI applied -- improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata -- add stronger schema validation around upstream CIRCL payloads and AI-generated JSON +- [x] expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent +- [x] add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" +- [x] preserve dependency manifest location through scan and fix flows so monorepo remediation targets the correct workspace +- [x] add regression tests for dependency parsing and GitHub scan edge cases +- [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied +- [x] improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata +- [x] add stronger schema validation around upstream CIRCL payloads and AI-generated JSON diff --git a/package-lock.json b/package-lock.json index b973b8b..7a7818a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@radix-ui/themes": "^3.3.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", @@ -455,6 +456,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1116,116 +1155,1646 @@ "node": ">= 10" } }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "license": "MIT" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/rect": "1.1.1" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/react-primitive": "2.1.3" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@radix-ui/themes": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz", + "integrity": "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==", "license": "MIT", - "engines": { - "node": ">=12.4.0" + "dependencies": { + "@radix-ui/colors": "^3.0.0", + "classnames": "^2.3.2", + "radix-ui": "^1.1.3", + "react-remove-scroll-bar": "^2.3.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@rtsao/scc": { @@ -1561,7 +3130,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1571,7 +3140,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2204,6 +3773,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2601,6 +4182,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2660,7 +4247,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2795,6 +4382,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3708,6 +5301,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -5434,6 +7036,83 @@ ], "license": "MIT" }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -5462,6 +7141,75 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6432,6 +8180,58 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index da4b137..6042c49 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "npm run test:compile && node --test .test-dist/tests/*.test.js" }, "dependencies": { + "@radix-ui/themes": "^3.3.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/src/app/api/ai/alerts/investigate/[id]/route.ts b/src/app/api/ai/alerts/investigate/[id]/route.ts new file mode 100644 index 0000000..8c96cfb --- /dev/null +++ b/src/app/api/ai/alerts/investigate/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applySearchResultPreferences, matchesSearchState } from "@/lib/search"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { generateAlertInvestigation } from "@/lib/ai-service"; +import { getLatestCVEsServer } from "@/lib/server-api"; +import { getSeverityFromScore } from "@/lib/utils"; +import { getAlertRuleForUser } from "@/lib/workspace-store"; + +const ALERT_SAMPLE_SIZE = 80; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const rule = await getAlertRuleForUser(session.userId, id); + + if (!rule) { + return applyWorkspaceSession(NextResponse.json({ error: "Alert rule not found" }, { status: 404 }), session); + } + + const sample = await getLatestCVEsServer(1, ALERT_SAMPLE_SIZE).catch(() => []); + const matching = applySearchResultPreferences( + sample.filter((cve) => matchesSearchState(cve, rule.search)), + rule.search + ); + + const investigation = await generateAlertInvestigation({ + rule: { + id: rule.id, + name: rule.name, + lastCheckedAt: rule.lastCheckedAt, + search: rule.search, + }, + matches: matching.slice(0, 8).map((cve) => ({ + id: cve.id, + summary: cve.summary || cve.description || "No summary available", + severity: getSeverityFromScore(cve.cvss3 ?? cve.cvss), + kev: Boolean(cve.kev), + published: cve.published || "", + modified: cve.modified || cve.published || "", + unread: isUnreadMatch(cve.modified ?? cve.published ?? "", rule.lastCheckedAt), + })), + }); + + return applyWorkspaceSession(NextResponse.json(investigation), session); +}, { + route: "/api/ai/alerts/investigate/[id]", + errorMessage: "Failed to investigate alert rule with AI", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isUnreadMatch(modified: string, lastCheckedAt: string | null): boolean { + if (!lastCheckedAt) { + return true; + } + + const modifiedTs = Date.parse(modified); + const checkedTs = Date.parse(lastCheckedAt); + if (Number.isNaN(modifiedTs) || Number.isNaN(checkedTs)) { + return true; + } + + return modifiedTs > checkedTs; +} diff --git a/src/app/api/ai/cve/[id]/route.ts b/src/app/api/ai/cve/[id]/route.ts index 1e5abc4..b705c19 100644 --- a/src/app/api/ai/cve/[id]/route.ts +++ b/src/app/api/ai/cve/[id]/route.ts @@ -1,18 +1,48 @@ import { NextRequest, NextResponse } from "next/server"; -import { getCVEByIdServer } from "@/lib/server-api"; -import { generateCveInsight } from "@/lib/ai"; +import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; +import { listProjects } from "@/lib/projects-store"; +import { generateCveInsight } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { readTriageRecordForUser } from "@/lib/workspace-store"; +import { CVEDetail } from "@/lib/types"; -export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { - try { - const { id } = await context.params; - const body = await request.json().catch(() => null); - const detail = await getCVEByIdServer(decodeURIComponent(id)); - const insight = await generateCveInsight(detail, body?.settings); - return NextResponse.json(insight); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to generate AI CVE insight" }, - { status: 500 } - ); +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI insight" }, { status: 502 }), session); } + + const [epss, projects] = await Promise.all([ + getEPSSServer(detail.id).catch(() => null), + listProjects().catch(() => []), + ]); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const triage = body?.triage && typeof body.triage === "object" + ? body.triage + : await readTriageRecordForUser(session.userId, detail.id); + const insight = await generateCveInsight({ + detail, + epss, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); + return applyWorkspaceSession(NextResponse.json(insight), session); +}, { + route: "/api/ai/cve/[id]", + errorMessage: "Failed to generate AI CVE insight", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; } diff --git a/src/app/api/ai/digest/route.ts b/src/app/api/ai/digest/route.ts index 7f8a16e..da2ddfd 100644 --- a/src/app/api/ai/digest/route.ts +++ b/src/app/api/ai/digest/route.ts @@ -1,20 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { generateDigest } from "@/lib/ai"; +import { generateDigest } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; -export async function POST(request: NextRequest) { - try { - const body = await request.json().catch(() => null); - const digest = await generateDigest({ - watchlist: Array.isArray(body?.watchlist) ? body.watchlist : [], - alerts: Array.isArray(body?.alerts) ? body.alerts : [], - projects: Array.isArray(body?.projects) ? body.projects : [], - }, body?.settings); +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const digest = await generateDigest({ + watchlist: Array.isArray(body?.watchlist) ? body.watchlist : [], + alerts: Array.isArray(body?.alerts) ? body.alerts : [], + projects: Array.isArray(body?.projects) ? body.projects : [], + }); - return NextResponse.json(digest); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to generate digest" }, - { status: 500 } - ); - } -} + return applyWorkspaceSession(NextResponse.json(digest), session); +}, { + route: "/api/ai/digest", + errorMessage: "Failed to generate digest", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/app/api/ai/exposure/[id]/route.ts b/src/app/api/ai/exposure/[id]/route.ts new file mode 100644 index 0000000..2429b7f --- /dev/null +++ b/src/app/api/ai/exposure/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { generateExposureAssessment } from "@/lib/ai-service"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer } from "@/lib/server-api"; +import { CVEDetail } from "@/lib/types"; +import { listInventoryAssetsForUser, readTriageRecordForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI exposure" }, { status: 502 }), session); + } + + const [triage, inventoryAssets, projects] = await Promise.all([ + readTriageRecordForUser(session.userId, detail.id), + listInventoryAssetsForUser(session.userId), + listProjects().catch(() => []), + ]); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const assessment = await generateExposureAssessment({ + detail, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + inventoryAssets: inventoryAssets.map((asset) => ({ + id: asset.id, + name: asset.name, + vendor: asset.vendor, + product: asset.product, + version: asset.version, + environment: asset.environment, + criticality: asset.criticality, + notes: asset.notes, + })), + }); + + return applyWorkspaceSession(NextResponse.json(assessment), session); +}, { + route: "/api/ai/exposure/[id]", + errorMessage: "Failed to generate AI exposure assessment", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/api/ai/project/[id]/route.ts b/src/app/api/ai/project/[id]/route.ts new file mode 100644 index 0000000..e955a8a --- /dev/null +++ b/src/app/api/ai/project/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateProjectSummary } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { getProjectById } from "@/lib/projects-store"; +import { getCVEByIdServer } from "@/lib/server-api"; +import { readTriageMapForUser } from "@/lib/workspace-store"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { extractDescription, extractCVEId, getSeverityFromScore } from "@/lib/utils"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const project = await getProjectById(id); + + if (!project) { + return applyWorkspaceSession(NextResponse.json({ error: "Project not found" }, { status: 404 }), session); + } + + const triageMap = await readTriageMapForUser(session.userId); + const details = await Promise.all( + project.items.map(async (item) => { + try { + return await getCVEByIdServer(item.cveId); + } catch { + return null; + } + }) + ); + + const summary = await generateProjectSummary({ + project: { + id: project.id, + name: project.name, + description: project.description, + updatedAt: project.updatedAt, + items: project.items, + activity: project.activity, + }, + items: details.flatMap((detail) => { + if (!detail) { + return []; + } + + const cveId = extractCVEId(detail); + const triage = triageMap[cveId]; + const affectedProducts = detail.containers?.cna?.affected + ?.flatMap((item) => [item.product, item.vendor].filter((value): value is string => Boolean(value))) ?? []; + + return [{ + id: cveId, + summary: extractDescription(detail), + severity: getSeverityFromScore(detail.cvss3 ?? detail.cvss), + kev: Boolean(detail.kev), + triageStatus: triage?.status ?? "new", + owner: triage?.owner ?? "", + affectedProducts: Array.from(new Set(affectedProducts)).slice(0, 6), + published: detail.cveMetadata?.datePublished || detail.published || "", + }]; + }), + }); + + return applyWorkspaceSession(NextResponse.json({ + ...summary, + projectName: project.name, + }), session); +}, { + route: "/api/ai/project/[id]", + errorMessage: "Failed to generate AI project summary", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/app/api/ai/remediation/[id]/route.ts b/src/app/api/ai/remediation/[id]/route.ts new file mode 100644 index 0000000..376c90c --- /dev/null +++ b/src/app/api/ai/remediation/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateRemediationPlan } from "@/lib/ai-service"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; +import { CVEDetail } from "@/lib/types"; +import { readTriageRecordForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI remediation" }, { status: 502 }), session); + } + + const [epss, projects] = await Promise.all([ + getEPSSServer(detail.id).catch(() => null), + listProjects().catch(() => []), + ]); + const triage = body?.triage && typeof body.triage === "object" + ? body.triage + : await readTriageRecordForUser(session.userId, detail.id); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const remediation = await generateRemediationPlan({ + detail, + epss, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); + + return applyWorkspaceSession(NextResponse.json(remediation), session); +}, { + route: "/api/ai/remediation/[id]", + errorMessage: "Failed to generate AI remediation plan", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/api/ai/runs/route.ts b/src/app/api/ai/runs/route.ts new file mode 100644 index 0000000..726c418 --- /dev/null +++ b/src/app/api/ai/runs/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRecentAIRuns } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const rawLimit = request.nextUrl.searchParams.get("limit"); + const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 25; + const runs = await getRecentAIRuns(limit); + return NextResponse.json(runs); +}, { + route: "/api/ai/runs", + errorMessage: "Failed to load AI runs", + rateLimit: API_RATE_LIMITS.aiRead, +}); diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index d6e6bb1..fbaa146 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -1,21 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; -import { generateSearchInterpretation } from "@/lib/ai"; +import { generateSearchInterpretation } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest) { - try { - const body = await request.json().catch(() => null); - const prompt = typeof body?.prompt === "string" ? body.prompt : ""; +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const prompt = typeof body?.prompt === "string" ? body.prompt : ""; - if (!prompt.trim()) { - return NextResponse.json({ error: "prompt is required" }, { status: 400 }); - } - - const interpretation = await generateSearchInterpretation(prompt, body?.settings); - return NextResponse.json(interpretation); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to interpret search prompt" }, - { status: 500 } - ); + if (!prompt.trim()) { + return NextResponse.json({ error: "prompt is required" }, { status: 400 }); } -} + + const interpretation = await generateSearchInterpretation(prompt); + return NextResponse.json(interpretation); +}, { + route: "/api/ai/search", + errorMessage: "Failed to interpret search prompt", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/app/api/ai/triage/[id]/route.ts b/src/app/api/ai/triage/[id]/route.ts new file mode 100644 index 0000000..6a5eeaa --- /dev/null +++ b/src/app/api/ai/triage/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateTriageSuggestion } from "@/lib/ai-service"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; +import { CVEDetail } from "@/lib/types"; +import { readTriageRecordForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI triage" }, { status: 502 }), session); + } + + const [epss, projects] = await Promise.all([ + getEPSSServer(detail.id).catch(() => null), + listProjects().catch(() => []), + ]); + const triage = body?.triage && typeof body.triage === "object" + ? body.triage + : await readTriageRecordForUser(session.userId, detail.id); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const suggestion = await generateTriageSuggestion({ + detail, + epss, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); + + return applyWorkspaceSession(NextResponse.json(suggestion), session); +}, { + route: "/api/ai/triage/[id]", + errorMessage: "Failed to generate AI triage suggestion", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/api/ai/watchlist/review/route.ts b/src/app/api/ai/watchlist/review/route.ts new file mode 100644 index 0000000..bae9533 --- /dev/null +++ b/src/app/api/ai/watchlist/review/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateWatchlistReview, getRecentAIRuns } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer } from "@/lib/server-api"; +import { readTriageMapForUser, listWatchlistEntriesForUser } from "@/lib/workspace-store"; +import { CVEDetail } from "@/lib/types"; +import { extractDescription, extractCVEId, getSeverityFromScore } from "@/lib/utils"; + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const [watchlistEntries, triageMap, projects, recentRuns] = await Promise.all([ + listWatchlistEntriesForUser(session.userId), + readTriageMapForUser(session.userId), + listProjects().catch(() => []), + getRecentAIRuns(100).catch(() => []), + ]); + + const details = await Promise.all( + watchlistEntries.map(async (entry) => { + try { + const detail = await getCVEByIdServer(entry.cveId); + return { entry, detail }; + } catch { + return null; + } + }) + ); + + const previousReviewAt = recentRuns.find((run) => run.feature === "watchlist_analyst")?.createdAt ?? null; + const items = details.flatMap((result) => { + if (!result) { + return []; + } + + const cveId = extractCVEId(result.detail); + const triage = triageMap[cveId]; + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === cveId)); + return [{ + id: cveId, + summary: extractDescription(result.detail), + severity: getSeverityFromScore(result.detail.cvss3 ?? result.detail.cvss), + kev: Boolean(result.detail.kev), + addedAt: result.entry.addedAt, + triageStatus: triage?.status ?? "new", + triageUpdatedAt: triage?.updatedAt ?? result.entry.addedAt, + projectNames: relatedProjects.map((project) => project.name), + projectUpdatedAt: relatedProjects[0]?.updatedAt ?? null, + aliases: result.detail.aliases ?? [], + relatedIds: extractRelatedIds(result.detail), + affectedProducts: extractAffectedProducts(result.detail), + published: result.detail.cveMetadata?.datePublished || result.detail.published || result.entry.addedAt, + modified: result.detail.cveMetadata?.dateUpdated || result.detail.modified || result.entry.addedAt, + }]; + }); + + const review = await generateWatchlistReview({ + items, + previousReviewAt, + }); + + return applyWorkspaceSession(NextResponse.json(review), session); +}, { + route: "/api/ai/watchlist/review", + errorMessage: "Failed to generate AI watchlist review", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function extractRelatedIds(detail: CVEDetail): string[] { + const related = new Set(); + const record = detail as unknown as Record; + + for (const alias of detail.aliases ?? []) { + related.add(alias); + } + + for (const key of ["linked_vulnerabilities", "related_vulnerabilities", "vulnerabilities", "related"]) { + const value = record[key]; + if (!Array.isArray(value)) continue; + for (const item of value) { + if (typeof item === "string") { + related.add(item); + continue; + } + + if (item && typeof item === "object") { + const objectValue = item as Record; + for (const field of ["id", "cve", "vulnerability"]) { + if (typeof objectValue[field] === "string") { + related.add(objectValue[field] as string); + } + } + } + } + } + + related.delete(extractCVEId(detail)); + return Array.from(related).slice(0, 8); +} + +function extractAffectedProducts(detail: CVEDetail): string[] { + const items = detail.containers?.cna?.affected?.flatMap((entry) => [entry.product, entry.vendor].filter((value): value is string => Boolean(value))) ?? []; + return Array.from(new Set(items)).slice(0, 6); +} diff --git a/src/app/api/alerts/[id]/route.ts b/src/app/api/alerts/[id]/route.ts new file mode 100644 index 0000000..aade213 --- /dev/null +++ b/src/app/api/alerts/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { deleteAlertRuleForUser, markAlertRuleCheckedForUser } from "@/lib/workspace-store"; + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const deleted = await deleteAlertRuleForUser(session.userId, decodeURIComponent(id)); + const response = deleted + ? NextResponse.json({ success: true }) + : NextResponse.json({ error: "Alert rule not found" }, { status: 404 }); + return applyWorkspaceSession(response, session); +}, { + route: "/api/alerts/[id]", + errorMessage: "Failed to delete alert rule", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +export const PATCH = withRouteProtection(async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const response = NextResponse.json(await markAlertRuleCheckedForUser(session.userId, decodeURIComponent(id))); + return applyWorkspaceSession(response, session); +}, { + route: "/api/alerts/[id]", + errorMessage: "Failed to update alert rule", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/alerts/mark-all/route.ts b/src/app/api/alerts/mark-all/route.ts new file mode 100644 index 0000000..ed2919e --- /dev/null +++ b/src/app/api/alerts/mark-all/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { markAllAlertRulesCheckedForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await markAllAlertRulesCheckedForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/alerts/mark-all", + errorMessage: "Failed to mark alerts as checked", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/alerts/route.ts b/src/app/api/alerts/route.ts new file mode 100644 index 0000000..aca3d9b --- /dev/null +++ b/src/app/api/alerts/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { normalizeSearchState } from "@/lib/search"; +import { createAlertRuleForUser, listAlertRulesForUser } from "@/lib/workspace-store"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await listAlertRulesForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/alerts", + errorMessage: "Failed to load alert rules", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const name = typeof body?.name === "string" ? body.name.trim() : ""; + + if (!name || !body?.search || typeof body.search !== "object") { + return applyWorkspaceSession(NextResponse.json({ error: "name and search are required" }, { status: 400 }), session); + } + + const response = NextResponse.json(await createAlertRuleForUser(session.userId, name, normalizeSearchState(body.search))); + return applyWorkspaceSession(response, session); +}, { + route: "/api/alerts", + errorMessage: "Failed to create alert rule", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/github/fix/route.ts b/src/app/api/github/fix/route.ts index d01f72a..6480250 100644 --- a/src/app/api/github/fix/route.ts +++ b/src/app/api/github/fix/route.ts @@ -18,13 +18,20 @@ import { generateVulnerabilityFix, extractFixedVersion } from "@/lib/ai-fix"; import { FixRequestPayload, FixResponse, + FixFileChange, + ParsedDependency, RepoFileContent, } from "@/lib/github-types"; import { AISettings, AIProvider } from "@/lib/types"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; const MAX_SOURCE_FILES = 5; +const MAX_ALLOWED_FILE_CHANGES = 8; +const MAX_FILE_CONTENT_BYTES = 200_000; +const VALID_PROVIDERS: AIProvider[] = ["heuristic", "openai", "anthropic"]; +const REPO_FULL_NAME_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; -export async function POST(request: NextRequest) { +export const POST = withRouteProtection(async function POST(request: NextRequest) { if (!isGitHubTokenConfigured()) { return NextResponse.json( { error: "GITHUB_TOKEN is not configured" }, @@ -33,20 +40,33 @@ export async function POST(request: NextRequest) { } try { - const body: FixRequestPayload = await request.json(); + const body: FixRequestPayload | null = await request.json().catch(() => null); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName.trim() : ""; + const vulnerability = body?.vulnerability; + const matchedDependency = body?.matchedDependency; - if (!body.repoFullName || !body.vulnerability || !body.matchedDependency) { + if (!repoFullName || !REPO_FULL_NAME_PATTERN.test(repoFullName) || !vulnerability || !isValidMatchedDependency(matchedDependency)) { return NextResponse.json( { error: "Missing required fields: repoFullName, vulnerability, matchedDependency" }, { status: 400 } ); } - const { repoFullName, vulnerability, matchedDependency, aiSettings } = body; + const aiSettings = body?.aiSettings; const fixedVersion = extractFixedVersion(vulnerability, matchedDependency.name); - const dependencyFiles = await fetchRepoDependencyFiles(repoFullName); + const dependencyFiles = selectRelevantDependencyFiles( + await fetchRepoDependencyFiles(repoFullName), + matchedDependency + ); + + if (dependencyFiles.length === 0) { + return NextResponse.json( + { error: "Could not locate the dependency manifest for this package in the repository." }, + { status: 422 } + ); + } const sourceFiles: RepoFileContent[] = []; try { @@ -83,6 +103,19 @@ export async function POST(request: NextRequest) { aiSettings ? normalizeAISettingsFromRequest(aiSettings) : undefined ); + try { + validateFixFileChanges( + fixResult.fileChanges, + new Set([...dependencyFiles, ...sourceFiles].map((file) => file.path)) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid generated fix changes"; + return NextResponse.json( + { error: message, analysis: fixResult.analysis }, + { status: 422 } + ); + } + if (fixResult.fileChanges.length === 0) { return NextResponse.json( { @@ -213,9 +246,11 @@ export async function POST(request: NextRequest) { console.error(`[fix-route] Unexpected error:`, message); return NextResponse.json({ error: message }, { status: 500 }); } -} - -const VALID_PROVIDERS: AIProvider[] = ["heuristic", "openai", "anthropic"]; +}, { + route: "/api/github/fix", + errorMessage: "Failed to create GitHub fix pull request", + rateLimit: API_RATE_LIMITS.githubWrites, +}); const normalizeAISettingsFromRequest = ( raw: Record @@ -230,3 +265,95 @@ const normalizeAISettingsFromRequest = ( apiKey: typeof raw.apiKey === "string" ? raw.apiKey : undefined, }; }; + +const isValidMatchedDependency = (value: unknown): value is ParsedDependency => { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + const manifestPath = typeof record.manifestPath === "string" ? record.manifestPath : undefined; + const lockfilePath = typeof record.lockfilePath === "string" ? record.lockfilePath : undefined; + + return ( + typeof record.name === "string" && + typeof record.version === "string" && + typeof record.isDev === "boolean" && + (record.ecosystem === "npm" || record.ecosystem === "Packagist") && + isSafeRepoPath(manifestPath) && + isSafeRepoPath(lockfilePath) + ); +}; + +const isSafeRepoPath = (value: string | undefined): boolean => { + if (value === undefined) { + return true; + } + + return value.length > 0 && !value.startsWith("/") && !value.includes("\\") && !/(^|\/)\.\.(\/|$)/.test(value); +}; + +const getParentDir = (filePath: string | undefined): string => { + if (!filePath) { + return ""; + } + + const lastSlash = filePath.lastIndexOf("/"); + return lastSlash === -1 ? "" : filePath.slice(0, lastSlash); +}; + +const selectRelevantDependencyFiles = ( + files: RepoFileContent[], + dependency: ParsedDependency +): RepoFileContent[] => { + const preferredPaths = new Set( + [dependency.manifestPath, dependency.lockfilePath].filter((value): value is string => Boolean(value)) + ); + + if (preferredPaths.size > 0) { + return files.filter((file) => preferredPaths.has(file.path)); + } + + const sourceDirectory = dependency.sourceDirectory ?? getParentDir(dependency.manifestPath) ?? getParentDir(dependency.lockfilePath); + if (!sourceDirectory) { + return files; + } + + return files.filter((file) => getParentDir(file.path) === sourceDirectory); +}; + +const validateFixFileChanges = (fileChanges: FixFileChange[], allowedPaths: Set): void => { + if (fileChanges.length > MAX_ALLOWED_FILE_CHANGES) { + throw new Error(`Generated fix exceeds the maximum of ${MAX_ALLOWED_FILE_CHANGES} files`); + } + + const seenPaths = new Set(); + + for (const change of fileChanges) { + if (!isSafeRepoPath(change.path)) { + throw new Error(`Generated fix contains an unsafe path: ${change.path}`); + } + + if (!allowedPaths.has(change.path)) { + throw new Error(`Generated fix tried to modify an unapproved file: ${change.path}`); + } + + if (isLockfilePath(change.path)) { + throw new Error(`Generated fix tried to modify a lock file: ${change.path}`); + } + + if (seenPaths.has(change.path)) { + throw new Error(`Generated fix contains duplicate file changes for ${change.path}`); + } + + seenPaths.add(change.path); + + if (Buffer.byteLength(change.content, "utf8") > MAX_FILE_CONTENT_BYTES) { + throw new Error(`Generated fix content is too large for ${change.path}`); + } + } +}; + +const isLockfilePath = (filePath: string): boolean => { + return filePath.endsWith("package-lock.json") || filePath.endsWith("pnpm-lock.yaml") || filePath.endsWith("composer.lock"); +}; diff --git a/src/app/api/github/monitored/route.ts b/src/app/api/github/monitored/route.ts index ee7fc28..f0a6eb0 100644 --- a/src/app/api/github/monitored/route.ts +++ b/src/app/api/github/monitored/route.ts @@ -4,8 +4,12 @@ import { addMonitoredRepo, removeMonitoredRepo, } from "@/lib/monitored-repos-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET() { +const isRepoFullName = (value: string): boolean => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); + +export const GET = withRouteProtection(async function GET(_request: NextRequest) { + void _request; try { const repos = await listMonitoredRepos(); return NextResponse.json(repos); @@ -13,22 +17,27 @@ export async function GET() { const message = error instanceof Error ? error.message : "Failed to list monitored repos"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}, { + route: "/api/github/monitored", + errorMessage: "Failed to list monitored repositories", + rateLimit: API_RATE_LIMITS.githubReads, +}); -export async function POST(request: NextRequest) { +export const POST = withRouteProtection(async function POST(request: NextRequest) { try { - const body = await request.json(); + const body = await request.json().catch(() => null); + const fullName = typeof body?.fullName === "string" ? body.fullName.trim() : ""; - if (!body.fullName || typeof body.fullName !== "string") { + if (!fullName || !isRepoFullName(fullName)) { return NextResponse.json({ error: "Missing required field: fullName" }, { status: 400 }); } const repo = await addMonitoredRepo({ - githubId: body.githubId ?? 0, - fullName: body.fullName, - htmlUrl: body.htmlUrl ?? "", - isPrivate: body.isPrivate ?? false, - defaultBranch: body.defaultBranch ?? "main", + githubId: typeof body?.githubId === "number" ? body.githubId : 0, + fullName, + htmlUrl: typeof body?.htmlUrl === "string" ? body.htmlUrl : "", + isPrivate: body?.isPrivate === true, + defaultBranch: typeof body?.defaultBranch === "string" && body.defaultBranch.trim() ? body.defaultBranch : "main", }); return NextResponse.json(repo, { status: 201 }); @@ -36,9 +45,13 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : "Failed to add monitored repo"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}, { + route: "/api/github/monitored", + errorMessage: "Failed to add monitored repository", + rateLimit: API_RATE_LIMITS.githubWrites, +}); -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest) { try { const { searchParams } = new URL(request.url); const repoId = searchParams.get("id"); @@ -58,4 +71,8 @@ export async function DELETE(request: NextRequest) { const message = error instanceof Error ? error.message : "Failed to remove monitored repo"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}, { + route: "/api/github/monitored", + errorMessage: "Failed to remove monitored repository", + rateLimit: API_RATE_LIMITS.githubWrites, +}); diff --git a/src/app/api/github/repos/route.ts b/src/app/api/github/repos/route.ts index 89b8f87..e98a751 100644 --- a/src/app/api/github/repos/route.ts +++ b/src/app/api/github/repos/route.ts @@ -1,7 +1,9 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { fetchGitHubRepos, isGitHubTokenConfigured, fetchTokenScopes } from "@/lib/github"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET() { +export const GET = withRouteProtection(async function GET(_request: NextRequest) { + void _request; if (!isGitHubTokenConfigured()) { return NextResponse.json( { error: "GITHUB_TOKEN is not configured. Add it to your .env file." }, @@ -20,4 +22,8 @@ export async function GET() { const message = error instanceof Error ? error.message : "Failed to fetch GitHub repos"; return NextResponse.json({ error: message }, { status: 502 }); } -} +}, { + route: "/api/github/repos", + errorMessage: "Failed to fetch GitHub repositories", + rateLimit: API_RATE_LIMITS.githubReads, +}); diff --git a/src/app/api/github/scan/route.ts b/src/app/api/github/scan/route.ts index 565f157..a5e2ea1 100644 --- a/src/app/api/github/scan/route.ts +++ b/src/app/api/github/scan/route.ts @@ -4,8 +4,27 @@ import { parseDependencyFiles } from "@/lib/dependency-parser"; import { queryOSVBatch } from "@/lib/osv"; import { updateLastScan } from "@/lib/monitored-repos-store"; import { DependencyScanResult } from "@/lib/github-types"; +import { listRepoScansForRepo, persistRepoScanResult } from "@/lib/repo-scans-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest) { +const isRepoFullName = (value: string): boolean => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const fullName = request.nextUrl.searchParams.get("fullName")?.trim() ?? ""; + + if (!fullName || !isRepoFullName(fullName)) { + return NextResponse.json({ error: "fullName is required" }, { status: 400 }); + } + + const scans = await listRepoScansForRepo(fullName); + return NextResponse.json(scans); +}, { + route: "/api/github/scan", + errorMessage: "Failed to load repository scan history", + rateLimit: API_RATE_LIMITS.githubScans, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { if (!isGitHubTokenConfigured()) { return NextResponse.json( { error: "GITHUB_TOKEN is not configured" }, @@ -14,11 +33,11 @@ export async function POST(request: NextRequest) { } try { - const body = await request.json(); - const fullName = body.fullName; - const branch = body.branch; + const body = await request.json().catch(() => null); + const fullName = typeof body?.fullName === "string" ? body.fullName.trim() : ""; + const branch = typeof body?.branch === "string" && body.branch.trim() ? body.branch : undefined; - if (!fullName || typeof fullName !== "string") { + if (!fullName || !isRepoFullName(fullName)) { return NextResponse.json( { error: "Missing required field: fullName" }, { status: 400 } @@ -37,6 +56,7 @@ export async function POST(request: NextRequest) { }; await updateLastScan(fullName, 0); + await persistRepoScanResult(fullName, branch ?? "default", emptyResult); return NextResponse.json(emptyResult); } @@ -52,10 +72,15 @@ export async function POST(request: NextRequest) { }; await updateLastScan(fullName, vulnerabilities.length); + await persistRepoScanResult(fullName, branch ?? "default", result); return NextResponse.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Scan failed"; - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: message }, { status: 502 }); } -} +}, { + route: "/api/github/scan", + errorMessage: "Failed to scan GitHub repository", + rateLimit: API_RATE_LIMITS.githubScans, +}); diff --git a/src/app/api/inventory/[id]/route.ts b/src/app/api/inventory/[id]/route.ts new file mode 100644 index 0000000..d5948f4 --- /dev/null +++ b/src/app/api/inventory/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { deleteInventoryAssetForUser, updateInventoryAssetForUser } from "@/lib/workspace-store"; +import { InventoryAssetRecord } from "@/lib/workspace-types"; + +export const PATCH = withRouteProtection(async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const asset = await updateInventoryAssetForUser(session.userId, id, normalizeInventoryAssetPatch(body)); + + if (!asset) { + return applyWorkspaceSession(NextResponse.json({ error: "Inventory asset not found" }, { status: 404 }), session); + } + + return applyWorkspaceSession(NextResponse.json(asset), session); +}, { + route: "/api/inventory/[id]", + errorMessage: "Failed to update inventory asset", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const deleted = await deleteInventoryAssetForUser(session.userId, id); + if (!deleted) { + return applyWorkspaceSession(NextResponse.json({ error: "Inventory asset not found" }, { status: 404 }), session); + } + + return applyWorkspaceSession(NextResponse.json({ success: true }), session); +}, { + route: "/api/inventory/[id]", + errorMessage: "Failed to delete inventory asset", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +function normalizeInventoryAssetPatch(value: unknown): Partial> { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return { + ...(typeof record.name === "string" ? { name: record.name } : {}), + ...(typeof record.vendor === "string" ? { vendor: record.vendor } : {}), + ...(typeof record.product === "string" ? { product: record.product } : {}), + ...(typeof record.version === "string" ? { version: record.version } : {}), + ...(typeof record.environment === "string" ? { environment: record.environment } : {}), + ...(record.criticality === "critical" || record.criticality === "high" || record.criticality === "medium" || record.criticality === "low" ? { criticality: record.criticality } : {}), + ...(typeof record.notes === "string" ? { notes: record.notes } : {}), + }; +} diff --git a/src/app/api/inventory/route.ts b/src/app/api/inventory/route.ts new file mode 100644 index 0000000..9171c35 --- /dev/null +++ b/src/app/api/inventory/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { createInventoryAssetForUser, listInventoryAssetsForUser } from "@/lib/workspace-store"; +import { InventoryAssetRecord } from "@/lib/workspace-types"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await listInventoryAssetsForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/inventory", + errorMessage: "Failed to load inventory assets", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const asset = await createInventoryAssetForUser(session.userId, normalizeInventoryAssetInput(body)); + return applyWorkspaceSession(NextResponse.json(asset, { status: 201 }), session); +}, { + route: "/api/inventory", + errorMessage: "Failed to create inventory asset", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +function normalizeInventoryAssetInput(value: unknown): Omit { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return { + name: typeof record.name === "string" ? record.name : "", + vendor: typeof record.vendor === "string" ? record.vendor : "", + product: typeof record.product === "string" ? record.product : "", + version: typeof record.version === "string" ? record.version : "", + environment: typeof record.environment === "string" ? record.environment : "", + criticality: record.criticality === "critical" || record.criticality === "high" || record.criticality === "medium" || record.criticality === "low" ? record.criticality : "medium", + notes: typeof record.notes === "string" ? record.notes : "", + }; +} diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..8d7556c --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { + deleteNotificationPreferenceForUser, + updateNotificationPreferenceForUser, +} from "@/lib/notifications-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const PATCH = withRouteProtection(async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + + const preference = await updateNotificationPreferenceForUser(session.userId, id, { + teamName: typeof body?.teamName === "string" ? body.teamName : undefined, + channel: typeof body?.channel === "string" ? body.channel as "in_app" | "email" | "slack" | "webhook" : undefined, + destination: typeof body?.destination === "string" ? body.destination : undefined, + cadence: typeof body?.cadence === "string" ? body.cadence as "daily" | "weekly" : undefined, + enabled: typeof body?.enabled === "boolean" ? body.enabled : undefined, + }); + + if (!preference) { + return NextResponse.json({ error: "Notification schedule not found" }, { status: 404 }); + } + + return applyWorkspaceSession(NextResponse.json(preference), session); +}, { + route: "/api/notifications/[id]", + errorMessage: "Failed to update notification schedule", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const success = await deleteNotificationPreferenceForUser(session.userId, id); + + if (!success) { + return NextResponse.json({ error: "Notification schedule not found" }, { status: 404 }); + } + + return applyWorkspaceSession(NextResponse.json({ success: true }), session); +}, { + route: "/api/notifications/[id]", + errorMessage: "Failed to delete notification schedule", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..4d8e64d --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { + createNotificationPreferenceForUser, + listNotificationDeliveriesForUser, + listNotificationPreferencesForUser, + runDueNotificationDigestsForUser, +} from "@/lib/notifications-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + await runDueNotificationDigestsForUser(session.userId); + const [preferences, deliveries] = await Promise.all([ + listNotificationPreferencesForUser(session.userId), + listNotificationDeliveriesForUser(session.userId), + ]); + + return applyWorkspaceSession(NextResponse.json({ preferences, deliveries }), session); +}, { + route: "/api/notifications", + errorMessage: "Failed to load notifications", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const teamName = typeof body?.teamName === "string" ? body.teamName : ""; + const channel = typeof body?.channel === "string" ? body.channel : "in_app"; + const destination = typeof body?.destination === "string" ? body.destination : ""; + const cadence = typeof body?.cadence === "string" ? body.cadence : "daily"; + + const preference = await createNotificationPreferenceForUser(session.userId, { + teamName, + channel: channel as "in_app" | "email" | "slack" | "webhook", + destination, + cadence: cadence as "daily" | "weekly", + }); + + return applyWorkspaceSession(NextResponse.json(preference, { status: 201 }), session); +}, { + route: "/api/notifications", + errorMessage: "Failed to create notification schedule", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/notifications/run/route.ts b/src/app/api/notifications/run/route.ts new file mode 100644 index 0000000..1d83a63 --- /dev/null +++ b/src/app/api/notifications/run/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { runDueNotificationDigestsForUser } from "@/lib/notifications-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const deliveries = await runDueNotificationDigestsForUser(session.userId, { + force: true, + preview: body?.preview === true, + preferenceId: typeof body?.preferenceId === "string" ? body.preferenceId : undefined, + }); + + return applyWorkspaceSession(NextResponse.json(deliveries), session); +}, { + route: "/api/notifications/run", + errorMessage: "Failed to run notification digests", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/projects/[id]/items/route.ts b/src/app/api/projects/[id]/items/route.ts index 4fadfac..36e84bf 100644 --- a/src/app/api/projects/[id]/items/route.ts +++ b/src/app/api/projects/[id]/items/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { addProjectItem, removeProjectItem } from "@/lib/projects-store"; +import { addProjectItem, removeProjectItem, updateProjectItem } from "@/lib/projects-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const body = await request.json().catch(() => null); const cveId = typeof body?.cveId === "string" ? body.cveId.trim() : ""; @@ -17,9 +18,13 @@ export async function POST(request: NextRequest, context: { params: Promise<{ id } return NextResponse.json(project); -} +}, { + route: "/api/projects/[id]/items", + errorMessage: "Failed to add project item", + rateLimit: API_RATE_LIMITS.projectMutations, +}); -export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const body = await request.json().catch(() => null); const cveId = typeof body?.cveId === "string" ? body.cveId.trim() : ""; @@ -34,4 +39,47 @@ export async function DELETE(request: NextRequest, context: { params: Promise<{ } return NextResponse.json(project); -} +}, { + route: "/api/projects/[id]/items", + errorMessage: "Failed to remove project item", + rateLimit: API_RATE_LIMITS.projectMutations, +}); + +export const PATCH = withRouteProtection(async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + const body = await request.json().catch(() => null); + const cveId = typeof body?.cveId === "string" ? body.cveId.trim() : ""; + + if (!cveId) { + return NextResponse.json({ error: "cveId is required" }, { status: 400 }); + } + + const project = await updateProjectItem(id, cveId, { + note: typeof body?.note === "string" ? body.note : undefined, + owner: typeof body?.owner === "string" ? body.owner : undefined, + remediationState: typeof body?.remediationState === "string" + ? body.remediationState as "not_started" | "planned" | "in_progress" | "validated" | "deferred" | "exception" + : undefined, + slaDueAt: typeof body?.slaDueAt === "string" || body?.slaDueAt === null ? body.slaDueAt : undefined, + exception: body?.exception && typeof body.exception === "object" + ? { + reason: typeof body.exception.reason === "string" ? body.exception.reason : "", + approvedBy: typeof body.exception.approvedBy === "string" ? body.exception.approvedBy : "", + expiresAt: typeof body.exception.expiresAt === "string" || body.exception.expiresAt === null ? body.exception.expiresAt : null, + notes: typeof body.exception.notes === "string" ? body.exception.notes : "", + } + : body?.exception === null + ? null + : undefined, + }); + + if (!project) { + return NextResponse.json({ error: "Project or item not found" }, { status: 404 }); + } + + return NextResponse.json(project); +}, { + route: "/api/projects/[id]/items", + errorMessage: "Failed to update project item", + rateLimit: API_RATE_LIMITS.projectMutations, +}); diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index 024a4a4..ce17c0d 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -1,7 +1,8 @@ -import { NextResponse } from "next/server"; -import { deleteProject } from "@/lib/projects-store"; +import { NextRequest, NextResponse } from "next/server"; +import { deleteProject, updateProject } from "@/lib/projects-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { +export const DELETE = withRouteProtection(async function DELETE(_request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const success = await deleteProject(id); @@ -10,4 +11,31 @@ export async function DELETE(_request: Request, context: { params: Promise<{ id: } return NextResponse.json({ success: true }); -} +}, { + route: "/api/projects/[id]", + errorMessage: "Failed to delete project", + rateLimit: API_RATE_LIMITS.projectMutations, +}); + +export const PATCH = withRouteProtection(async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + const body = await request.json().catch(() => null); + const project = await updateProject(id, { + name: typeof body?.name === "string" ? body.name : undefined, + description: typeof body?.description === "string" ? body.description : undefined, + owner: typeof body?.owner === "string" ? body.owner : undefined, + dueAt: typeof body?.dueAt === "string" || body?.dueAt === null ? body.dueAt : undefined, + labels: Array.isArray(body?.labels) ? body.labels.filter((item: unknown): item is string => typeof item === "string") : undefined, + status: typeof body?.status === "string" ? body.status as "planned" | "active" | "at_risk" | "done" : undefined, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + return NextResponse.json(project); +}, { + route: "/api/projects/[id]", + errorMessage: "Failed to update project", + rateLimit: API_RATE_LIMITS.projectMutations, +}); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 07bf55a..009f5d3 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { createProject, listProjects, normalizeProjectName } from "@/lib/projects-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET() { +export const GET = withRouteProtection(async function GET(_request: NextRequest) { + void _request; const projects = await listProjects(); return NextResponse.json(projects); -} +}, { + route: "/api/projects", + errorMessage: "Failed to load projects", + rateLimit: API_RATE_LIMITS.projectReads, +}); -export async function POST(request: NextRequest) { +export const POST = withRouteProtection(async function POST(request: NextRequest) { const body = await request.json().catch(() => null); const name = typeof body?.name === "string" ? normalizeProjectName(body.name) : ""; const description = typeof body?.description === "string" ? body.description : ""; @@ -17,4 +23,8 @@ export async function POST(request: NextRequest) { const project = await createProject({ name, description }); return NextResponse.json(project, { status: 201 }); -} +}, { + route: "/api/projects", + errorMessage: "Failed to create project", + rateLimit: API_RATE_LIMITS.projectMutations, +}); diff --git a/src/app/api/prompt-templates/[id]/route.ts b/src/app/api/prompt-templates/[id]/route.ts new file mode 100644 index 0000000..b0e164e --- /dev/null +++ b/src/app/api/prompt-templates/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { deletePromptTemplateForUser } from "@/lib/workspace-store"; + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const deleted = await deletePromptTemplateForUser(session.userId, decodeURIComponent(id)); + const response = deleted + ? NextResponse.json({ success: true }) + : NextResponse.json({ error: "Prompt template not found" }, { status: 404 }); + return applyWorkspaceSession(response, session); +}, { + route: "/api/prompt-templates/[id]", + errorMessage: "Failed to delete prompt template", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/prompt-templates/route.ts b/src/app/api/prompt-templates/route.ts new file mode 100644 index 0000000..3c4d467 --- /dev/null +++ b/src/app/api/prompt-templates/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { createPromptTemplateForUser, listPromptTemplatesForUser } from "@/lib/workspace-store"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await listPromptTemplatesForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/prompt-templates", + errorMessage: "Failed to load prompt templates", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : ""; + + if (!name || !prompt) { + return applyWorkspaceSession(NextResponse.json({ error: "name and prompt are required" }, { status: 400 }), session); + } + + const response = NextResponse.json(await createPromptTemplateForUser(session.userId, name, prompt), { status: 201 }); + return applyWorkspaceSession(response, session); +}, { + route: "/api/prompt-templates", + errorMessage: "Failed to create prompt template", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index ffc9e76..fb4d4dd 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; const API_BASE = "https://vulnerability.circl.lu/api"; const REQUEST_TIMEOUT_MS = 10_000; +type ProxyFetchOptions = RequestInit & { next?: { revalidate: number } }; const ALLOWED_PATH_PATTERNS = [ /^\/vulnerability\/\?(.*)$/u, /^\/vulnerability\/[A-Za-z0-9._:-]+(\?.*)?$/u, @@ -12,7 +14,7 @@ const ALLOWED_PATH_PATTERNS = [ /^\/cwe\/[A-Za-z0-9._:-]+$/u, ]; -export async function GET(request: NextRequest) { +export const GET = withRouteProtection(async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const path = searchParams.get("path"); @@ -30,14 +32,15 @@ export async function GET(request: NextRequest) { const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - const res = await fetch(targetUrl, { + const options: ProxyFetchOptions = { headers: { Accept: "application/json", "User-Agent": "CVESearch-WebApp/1.0", }, next: { revalidate: 60 }, signal: controller.signal, - }); + }; + const res = await fetch(targetUrl, options); if (!res.ok) { return NextResponse.json( @@ -63,7 +66,11 @@ export async function GET(request: NextRequest) { } finally { clearTimeout(timeout); } -} +}, { + route: "/api/proxy", + errorMessage: "Proxy error", + rateLimit: API_RATE_LIMITS.proxy, +}); function isAllowedPath(path: string): boolean { return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(path)); diff --git a/src/app/api/saved-views/[id]/route.ts b/src/app/api/saved-views/[id]/route.ts new file mode 100644 index 0000000..e8d033d --- /dev/null +++ b/src/app/api/saved-views/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { deleteSavedViewForUser } from "@/lib/workspace-store"; + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const deleted = await deleteSavedViewForUser(session.userId, decodeURIComponent(id)); + const response = deleted + ? NextResponse.json({ success: true }) + : NextResponse.json({ error: "Saved view not found" }, { status: 404 }); + return applyWorkspaceSession(response, session); +}, { + route: "/api/saved-views/[id]", + errorMessage: "Failed to delete saved view", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/saved-views/route.ts b/src/app/api/saved-views/route.ts new file mode 100644 index 0000000..e2d779d --- /dev/null +++ b/src/app/api/saved-views/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { normalizeSearchState } from "@/lib/search"; +import { createSavedViewForUser, listSavedViewsForUser } from "@/lib/workspace-store"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await listSavedViewsForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/saved-views", + errorMessage: "Failed to load saved views", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const name = typeof body?.name === "string" ? body.name.trim() : ""; + + if (!name || !body?.search || typeof body.search !== "object") { + return applyWorkspaceSession(NextResponse.json({ error: "name and search are required" }, { status: 400 }), session); + } + + const response = NextResponse.json(await createSavedViewForUser(session.userId, name, normalizeSearchState(body.search))); + return applyWorkspaceSession(response, session); +}, { + route: "/api/saved-views", + errorMessage: "Failed to create saved view", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/triage/[id]/route.ts b/src/app/api/triage/[id]/route.ts new file mode 100644 index 0000000..b0331c7 --- /dev/null +++ b/src/app/api/triage/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { normalizeTriageRecord } from "@/lib/triage-shared"; +import { readTriageRecordForUser, writeTriageRecordForUser } from "@/lib/workspace-store"; + +export const GET = withRouteProtection(async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const response = NextResponse.json(await readTriageRecordForUser(session.userId, decodeURIComponent(id))); + return applyWorkspaceSession(response, session); +}, { + route: "/api/triage/[id]", + errorMessage: "Failed to load triage record", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const PUT = withRouteProtection(async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + + if (!body || typeof body !== "object") { + return applyWorkspaceSession(NextResponse.json({ error: "triage body is required" }, { status: 400 }), session); + } + + const response = NextResponse.json(await writeTriageRecordForUser(session.userId, normalizeTriageRecord({ + cveId: decodeURIComponent(id), + status: body.status, + owner: body.owner, + notes: body.notes, + tags: body.tags, + updatedAt: body.updatedAt, + activity: body.activity, + }))); + return applyWorkspaceSession(response, session); +}, { + route: "/api/triage/[id]", + errorMessage: "Failed to save triage record", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/triage/route.ts b/src/app/api/triage/route.ts new file mode 100644 index 0000000..5cd8274 --- /dev/null +++ b/src/app/api/triage/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { readTriageMapForUser } from "@/lib/workspace-store"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await readTriageMapForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/triage", + errorMessage: "Failed to load triage records", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); diff --git a/src/app/api/watchlist/route.ts b/src/app/api/watchlist/route.ts new file mode 100644 index 0000000..c1204fa --- /dev/null +++ b/src/app/api/watchlist/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { listWatchlist, removeWatchlistEntries, toggleWatchlistEntry } from "@/lib/workspace-store"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await listWatchlist(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/watchlist", + errorMessage: "Failed to load watchlist", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const id = typeof body?.id === "string" ? body.id.trim() : ""; + + if (!id) { + return applyWorkspaceSession(NextResponse.json({ error: "id is required" }, { status: 400 }), session); + } + + const response = NextResponse.json(await toggleWatchlistEntry(session.userId, id)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/watchlist", + errorMessage: "Failed to update watchlist", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const ids = Array.isArray(body?.ids) ? body.ids.filter((value: unknown): value is string => typeof value === "string") : []; + + const response = NextResponse.json(await removeWatchlistEntries(session.userId, ids)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/watchlist", + errorMessage: "Failed to remove watchlist entries", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/workspace/conversations/[id]/messages/route.ts b/src/app/api/workspace/conversations/[id]/messages/route.ts new file mode 100644 index 0000000..9358c72 --- /dev/null +++ b/src/app/api/workspace/conversations/[id]/messages/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { buildUserWorkspaceMessage, answerWorkspaceQuestion } from "@/lib/workspace-assistant"; +import { appendWorkspaceConversationMessages } from "@/lib/workspace-assistant-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : ""; + + if (!prompt) { + return NextResponse.json({ error: "prompt is required" }, { status: 400 }); + } + + const userMessage = buildUserWorkspaceMessage(prompt); + const assistant = await answerWorkspaceQuestion(prompt, session.userId); + const conversation = await appendWorkspaceConversationMessages(session.userId, id, [userMessage, assistant.message]); + + if (!conversation) { + return NextResponse.json({ error: "Conversation not found" }, { status: 404 }); + } + + return applyWorkspaceSession(NextResponse.json(conversation), session); +}, { + route: "/api/workspace/conversations/[id]/messages", + errorMessage: "Failed to append workspace conversation message", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/workspace/conversations/[id]/route.ts b/src/app/api/workspace/conversations/[id]/route.ts new file mode 100644 index 0000000..23f0e6b --- /dev/null +++ b/src/app/api/workspace/conversations/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { getWorkspaceConversationForUser } from "@/lib/workspace-assistant-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const GET = withRouteProtection(async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const conversation = await getWorkspaceConversationForUser(session.userId, id); + + if (!conversation) { + return NextResponse.json({ error: "Conversation not found" }, { status: 404 }); + } + + return applyWorkspaceSession(NextResponse.json(conversation), session); +}, { + route: "/api/workspace/conversations/[id]", + errorMessage: "Failed to load workspace conversation", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); diff --git a/src/app/api/workspace/conversations/route.ts b/src/app/api/workspace/conversations/route.ts new file mode 100644 index 0000000..3557fe1 --- /dev/null +++ b/src/app/api/workspace/conversations/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { buildUserWorkspaceMessage, answerWorkspaceQuestion } from "@/lib/workspace-assistant"; +import { + appendWorkspaceConversationMessages, + createWorkspaceConversationForUser, + listWorkspaceConversationsForUser, +} from "@/lib/workspace-assistant-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const conversations = await listWorkspaceConversationsForUser(session.userId); + return applyWorkspaceSession(NextResponse.json(conversations), session); +}, { + route: "/api/workspace/conversations", + errorMessage: "Failed to load workspace conversations", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : ""; + const title = typeof body?.title === "string" ? body.title.trim() : prompt.slice(0, 48); + const conversation = await createWorkspaceConversationForUser(session.userId, title || "Workspace conversation"); + + if (!prompt) { + return applyWorkspaceSession(NextResponse.json(conversation, { status: 201 }), session); + } + + const userMessage = buildUserWorkspaceMessage(prompt); + const assistant = await answerWorkspaceQuestion(prompt, session.userId); + const updated = await appendWorkspaceConversationMessages(session.userId, conversation.id, [userMessage, assistant.message]); + + return applyWorkspaceSession(NextResponse.json(updated ?? conversation, { status: 201 }), session); +}, { + route: "/api/workspace/conversations", + errorMessage: "Failed to create workspace conversation", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); diff --git a/src/app/api/workspace/export/route.ts b/src/app/api/workspace/export/route.ts new file mode 100644 index 0000000..1c3c65b --- /dev/null +++ b/src/app/api/workspace/export/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { listProjects } from "@/lib/projects-store"; +import { + listAlertRulesForUser, + listInventoryAssetsForUser, + listPromptTemplatesForUser, + listSavedViewsForUser, + listWatchlist, + readTriageMapForUser, +} from "@/lib/workspace-store"; +import { WorkspaceExportSnapshot } from "@/lib/workspace-types"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const [watchlist, savedViews, promptTemplates, alertRules, inventoryAssets, triageMap, projects] = await Promise.all([ + listWatchlist(session.userId), + listSavedViewsForUser(session.userId), + listPromptTemplatesForUser(session.userId), + listAlertRulesForUser(session.userId), + listInventoryAssetsForUser(session.userId), + readTriageMapForUser(session.userId), + listProjects(), + ]); + + const snapshot: WorkspaceExportSnapshot = { + version: 1, + exportedAt: new Date().toISOString(), + watchlist, + savedViews, + promptTemplates, + alertRules, + inventoryAssets, + triageRecords: Object.values(triageMap), + projects, + }; + + const response = NextResponse.json(snapshot); + response.headers.set("Content-Disposition", `attachment; filename="cvesearch-workspace-${snapshot.exportedAt.slice(0, 10)}.json"`); + return applyWorkspaceSession(response, session); +}, { + route: "/api/workspace/export", + errorMessage: "Failed to export workspace data", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); diff --git a/src/app/api/workspace/import/route.ts b/src/app/api/workspace/import/route.ts new file mode 100644 index 0000000..4255407 --- /dev/null +++ b/src/app/api/workspace/import/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { importProjects } from "@/lib/projects-store"; +import { createDefaultTriageRecord, normalizeTriageRecord, TriageRecord } from "@/lib/triage-shared"; +import { ProjectRecord } from "@/lib/types"; +import { importWorkspaceStateForUser } from "@/lib/workspace-store"; +import { AlertRule, InventoryAssetRecord, PromptTemplateRecord, SavedView, WorkspaceImportMode } from "@/lib/workspace-types"; + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const mode: WorkspaceImportMode = body?.mode === "replace" ? "replace" : "merge"; + const snapshot = body?.snapshot; + + if (!snapshot || typeof snapshot !== "object") { + return applyWorkspaceSession(NextResponse.json({ error: "snapshot is required" }, { status: 400 }), session); + } + + const watchlist = Array.isArray(snapshot.watchlist) + ? snapshot.watchlist.filter((value: unknown): value is string => typeof value === "string") + : []; + const savedViews = Array.isArray(snapshot.savedViews) + ? snapshot.savedViews.flatMap((value: unknown) => (isSavedView(value) ? [value] : [])) + : []; + const promptTemplates = Array.isArray(snapshot.promptTemplates) + ? snapshot.promptTemplates.flatMap((value: unknown) => (isPromptTemplate(value) ? [value] : [])) + : []; + const alertRules = Array.isArray(snapshot.alertRules) + ? snapshot.alertRules.flatMap((value: unknown) => (isAlertRule(value) ? [value] : [])) + : []; + const inventoryAssets = Array.isArray(snapshot.inventoryAssets) + ? snapshot.inventoryAssets.flatMap((value: unknown) => (isInventoryAsset(value) ? [value] : [])) + : []; + const triageRecords = Array.isArray(snapshot.triageRecords) + ? snapshot.triageRecords.flatMap((value: unknown) => (isTriageRecord(value) ? [normalizeTriageRecord(value)] : [])) + : []; + const projects = Array.isArray(snapshot.projects) + ? snapshot.projects.flatMap((value: unknown) => (isProjectRecord(value) ? [value] : [])) + : []; + + await importWorkspaceStateForUser( + session.userId, + { + watchlist, + savedViews, + promptTemplates, + alertRules, + inventoryAssets, + triageRecords, + }, + mode + ); + await importProjects(projects, mode); + + const response = NextResponse.json({ + success: true, + mode, + imported: { + watchlist: watchlist.length, + savedViews: savedViews.length, + promptTemplates: promptTemplates.length, + alertRules: alertRules.length, + inventoryAssets: inventoryAssets.length, + triageRecords: triageRecords.length, + projects: projects.length, + }, + }); + + return applyWorkspaceSession(response, session); +}, { + route: "/api/workspace/import", + errorMessage: "Failed to import workspace data", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +function isSavedView(value: unknown): value is SavedView { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && typeof (value as Record).id === "string" + && typeof (value as Record).name === "string" + && typeof (value as Record).search === "object"; +} + +function isAlertRule(value: unknown): value is AlertRule { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && typeof (value as Record).id === "string" + && typeof (value as Record).name === "string" + && typeof (value as Record).search === "object"; +} + +function isPromptTemplate(value: unknown): value is PromptTemplateRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return typeof record.id === "string" + && typeof record.name === "string" + && typeof record.prompt === "string" + && typeof record.createdAt === "string" + && typeof record.updatedAt === "string"; +} + +function isInventoryAsset(value: unknown): value is InventoryAssetRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return typeof record.id === "string" + && typeof record.name === "string" + && typeof record.vendor === "string" + && typeof record.product === "string" + && typeof record.version === "string" + && typeof record.environment === "string" + && typeof record.criticality === "string" + && typeof record.notes === "string" + && typeof record.createdAt === "string" + && typeof record.updatedAt === "string"; +} + +function isTriageRecord(value: unknown): value is TriageRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + const fallback = createDefaultTriageRecord(typeof record.cveId === "string" ? record.cveId : ""); + + return typeof record.cveId === "string" + && typeof record.status === "string" + && typeof record.owner === "string" + && typeof record.notes === "string" + && Array.isArray(record.tags) + && typeof (record.updatedAt ?? fallback.updatedAt) === "string" + && Array.isArray(record.activity ?? fallback.activity); +} + +function isProjectRecord(value: unknown): value is ProjectRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return typeof record.id === "string" + && typeof record.name === "string" + && typeof record.description === "string" + && typeof record.createdAt === "string" + && typeof record.updatedAt === "string" + && Array.isArray(record.items) + && Array.isArray(record.activity); +} diff --git a/src/app/cve/[id]/page.tsx b/src/app/cve/[id]/page.tsx index f609873..c19055d 100644 --- a/src/app/cve/[id]/page.tsx +++ b/src/app/cve/[id]/page.tsx @@ -17,6 +17,8 @@ import CopyLinkButton from "@/components/CopyLinkButton"; import TriagePanel from "@/components/TriagePanel"; import ProjectPickerButton from "@/components/ProjectPickerButton"; import AICveInsightPanel from "@/components/AICveInsightPanel"; +import AIExposurePanel from "@/components/AIExposurePanel"; +import AIRemediationPlanPanel from "@/components/AIRemediationPlanPanel"; export default function CVEDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); @@ -52,12 +54,12 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string if (loading) { return ( -
-
-
-
-
-
+
+
+
+
+
+
); @@ -65,7 +67,7 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string if (error || !cve) { return ( -
+
@@ -86,12 +88,15 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string const modified = cve.cveMetadata?.dateUpdated || cve.modified; const assigner = cve.cveMetadata?.assignerShortName || cve.assigner; const state = cve.cveMetadata?.state || cve.state; - const references = normalizeReferences(cve); + const references = (cve.referenceMeta?.map((reference) => ({ + url: reference.url, + tags: reference.tags.length > 0 ? [...reference.tags, reference.type] : [reference.type], + })) ?? normalizeReferences(cve)); const affected = cve.containers?.cna?.affected || []; const problemTypes = cve.containers?.cna?.problemTypes || []; const metrics = cve.containers?.cna?.metrics || []; const aliases = cve.aliases?.filter((alias) => alias !== cveId) ?? []; - const linkedVulnerabilities = getLinkedVulnerabilities(cve, cveId); + const linkedVulnerabilities = cve.linkedVulnerabilities?.map((entry) => entry.id) ?? getLinkedVulnerabilities(cve, cveId); const comments = getComments(cve); const capecEntries = cve.capec ?? []; @@ -101,9 +106,9 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string : null; return ( -
+
{/* Back nav */} - + @@ -111,7 +116,7 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string {/* Header */} -
+

{cveId}

@@ -126,14 +131,14 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string /> )} {state && ( - {state} )}
-
+
{published && ( @@ -164,12 +169,16 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string
{/* Description */}
-

{description}

+

{description}

- + - + + + + + {aliases.length > 0 && (
@@ -587,19 +596,19 @@ function Section({ const [isOpen, setIsOpen] = useState(!collapsible); return ( -
+
+
+ + {error ?

{error}

: null} + + {investigation ? ( +
+

{investigation.summary}

+ +
+ +
+

Top Matches

+
+ {investigation.topMatches.map((match) => ( +
+
+ {match.id} + {match.unread ? Unread : null} +
+

{match.summary}

+

{match.rationale}

+
+ ))} +
+
+ +
+

Recommended Action

+

{investigation.recommendedAction}

+
+ +
+
+ ) : null} +
+ ); +} + +function Section({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ); +} diff --git a/src/components/AICveInsightPanel.tsx b/src/components/AICveInsightPanel.tsx index 2f9ff4b..9010353 100644 --- a/src/components/AICveInsightPanel.tsx +++ b/src/components/AICveInsightPanel.tsx @@ -1,10 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { AICveInsight } from "@/lib/types"; -import { readAISettings } from "@/lib/ai-settings"; +import { AICveInsight, CVEDetail } from "@/lib/types"; +import { loadTriageRecord, TRIAGE_UPDATED_EVENT } from "@/lib/triage"; -export default function AICveInsightPanel({ cveId }: { cveId: string }) { +export default function AICveInsightPanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { const [insight, setInsight] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -17,10 +17,11 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { setError(null); try { + const triage = await loadTriageRecord(cveId); const res = await fetch(`/api/ai/cve/${encodeURIComponent(cveId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings: readAISettings() }), + body: JSON.stringify({ triage, detail }), }); const data = await res.json().catch(() => null); if (!res.ok) { @@ -42,40 +43,67 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { } load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); return () => { cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); }; - }, [cveId]); + }, [cveId, detail]); return ( -
-
-

AI Insight

-

Summary, triage guidance, remediation notes, and related-vulnerability context.

+
+
+
+ + + +
+
+

AI Insight

+

Summary, triage guidance, remediation notes, and related-vulnerability context.

+
- {loading &&

Generating insight...

} + {loading &&

Generating insight...

} {error &&

{error}

} {insight && !loading && ( -
+
-

Summary

-

{insight.summary}

+

Summary

+

{insight.summary}

-

Triage Recommendation

- +

Triage Recommendation

+ {insight.triage.priority} - + {insight.triage.status} + + confidence: {insight.triage.confidence} +
-

{insight.triage.rationale}

-
    +

    {insight.triage.rationale}

    +
    + Owner recommendation: {insight.triage.ownerRecommendation} +
    +
    + {insight.triage.signals.map((signal) => ( +
    +
    + {signal.label} + {signal.level} +
    +

    {signal.value}

    +

    {signal.rationale}

    +
    + ))} +
    +
      {insight.triage.nextSteps.map((step) => (
    • {step} @@ -85,8 +113,22 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) {
-

Remediation Notes

-
    +

    Project Context

    +

    {insight.projectContext.summary}

    + {insight.projectContext.projectNames.length > 0 ? ( +
    + {insight.projectContext.projectNames.map((project) => ( + + Project: {project} + + ))} +
    + ) : null} +
+ +
+

Remediation Notes

+
    {insight.remediation.map((item) => (
  • {item} @@ -96,19 +138,19 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) {
-

Context Cluster

-

{insight.cluster.summary}

+

Context Cluster

+

{insight.cluster.summary}

- + Canonical: {insight.cluster.canonicalId} {insight.cluster.sourceIds.map((item) => ( - + Source: {item} ))} {insight.cluster.relatedIds.map((item) => ( - + Related: {item} ))} diff --git a/src/components/AIDigestPanel.tsx b/src/components/AIDigestPanel.tsx index af000ce..6598731 100644 --- a/src/components/AIDigestPanel.tsx +++ b/src/components/AIDigestPanel.tsx @@ -2,12 +2,11 @@ import { useEffect, useState } from "react"; import { AIDigest } from "@/lib/types"; -import { readWatchlist } from "@/lib/watchlist"; -import { readAlertRules } from "@/lib/alerts"; +import { loadWatchlist } from "@/lib/watchlist"; +import { loadAlertRules } from "@/lib/alerts"; import { listProjectsAPI } from "@/lib/projects-api"; import { getLatestCVEs } from "@/lib/api"; import { applySearchResultPreferences, matchesSearchState } from "@/lib/search"; -import { readAISettings } from "@/lib/ai-settings"; export default function AIDigestPanel() { const [digest, setDigest] = useState(null); @@ -22,12 +21,14 @@ export default function AIDigestPanel() { setError(null); try { - const [latest, projects] = await Promise.all([ + const [latest, projects, watchlist, alertRules] = await Promise.all([ getLatestCVEs(1, 80).catch(() => []), listProjectsAPI().catch(() => []), + loadWatchlist().catch(() => []), + loadAlertRules().catch(() => []), ]); - const alertPayload = readAlertRules().map((rule) => { + const alertPayload = alertRules.map((rule) => { const matching = applySearchResultPreferences( latest.filter((cve) => matchesSearchState(cve, rule.search)), rule.search @@ -43,8 +44,7 @@ export default function AIDigestPanel() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - settings: readAISettings(), - watchlist: readWatchlist().map((id) => ({ id })), + watchlist: watchlist.map((id) => ({ id })), alerts: alertPayload, projects: projects.map((project) => ({ name: project.name, @@ -80,26 +80,33 @@ export default function AIDigestPanel() { }, []); return ( -
-
-

AI Daily Digest

-

A summarized view across your watchlist, alerts, and projects.

+
+
+
+ + + +
+
+

AI Daily Digest

+

Summarized view across your watchlist, alerts, and projects.

+
- {loading &&

Compiling digest...

} + {loading &&

Compiling digest...

} {error &&

{error}

} {digest && !loading && ( -
-
+
+
{digest.headline}
{digest.sections.map((section) => ( -
+

{section.title}

-

{section.body}

-
    +

    {section.body}

    +
      {section.items.map((item) => (
    • {item} diff --git a/src/components/AIExposurePanel.tsx b/src/components/AIExposurePanel.tsx new file mode 100644 index 0000000..bf7eade --- /dev/null +++ b/src/components/AIExposurePanel.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AIExposureAssessment, CVEDetail } from "@/lib/types"; +import { INVENTORY_UPDATED_EVENT } from "@/lib/inventory"; +import { TRIAGE_UPDATED_EVENT } from "@/lib/triage"; + +export default function AIExposurePanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { + const [assessment, setAssessment] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/ai/exposure/${encodeURIComponent(cveId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ detail }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI exposure assessment"); + } + + if (!cancelled) { + setAssessment(data); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load AI exposure assessment"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); + window.addEventListener(INVENTORY_UPDATED_EVENT, load); + return () => { + cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); + window.removeEventListener(INVENTORY_UPDATED_EVENT, load); + }; + }, [cveId, detail]); + + return ( +
      +
      +
      +
      + + + +
      +
      +

      AI Exposure Agent

      +

      Maps against tracked inventory to estimate internal impact.

      +
      +
      + {assessment ? Likely impact: {assessment.likelyImpact} : null} +
      + + {loading ?

      Estimating internal exposure...

      : null} + {error ?

      {error}

      : null} + + {assessment && !loading ? ( +
      +

      {assessment.summary}

      + +
      + +
      +

      Matched Assets

      + {assessment.matchedAssets.length > 0 ? ( +
      + {assessment.matchedAssets.map((asset) => ( +
      +
      + {asset.assetName} + {asset.confidence} +
      +

      {asset.rationale}

      +
      + {asset.matchingSignals.map((signal) => ( + {signal} + ))} +
      +
      + ))} +
      + ) : ( +

      No tracked assets matched. Add inventory mappings in settings.

      + )} +
      + +
      +
      + ) : null} +
      + ); +} + +function Section({ title, items }: { title: string; items: string[] }) { + return ( +
      +

      {title}

      +
        + {items.map((item) => ( +
      • {item}
      • + ))} +
      +
      + ); +} diff --git a/src/components/AIProjectSummaryPanel.tsx b/src/components/AIProjectSummaryPanel.tsx new file mode 100644 index 0000000..24e6bdd --- /dev/null +++ b/src/components/AIProjectSummaryPanel.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { AIProjectSummary } from "@/lib/types"; + +export default function AIProjectSummaryPanel({ projectId }: { projectId: string }) { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState<"executive" | "analyst" | "engineering">("executive"); + + async function handleLoad() { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/ai/project/${encodeURIComponent(projectId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI project summary"); + } + + setSummary(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load AI project summary"); + } finally { + setLoading(false); + } + } + + const active = summary ? summary[view] : null; + + return ( +
      +
      +
      +
      + + + +
      +
      +

      AI Project Summary

      +

      Executive, analyst, and engineering views.

      +
      +
      + +
      + + {error ?

      {error}

      : null} + + {summary ? ( +
      +

      {summary.overview}

      + +
      + + + + + +
      + +
      + {(["executive", "analyst", "engineering"] as const).map((option) => ( + + ))} +
      + + {active ? ( +
      +

      {active.headline}

      +

      {active.summary}

      +
        + {active.bullets.map((item) => ( +
      • + {item} +
      • + ))} +
      +
      + ) : null} +
      + ) : null} +
      + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label}: {value} + + ); +} diff --git a/src/components/AIRemediationPlanPanel.tsx b/src/components/AIRemediationPlanPanel.tsx new file mode 100644 index 0000000..ce7189b --- /dev/null +++ b/src/components/AIRemediationPlanPanel.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AIRemediationPlan, CVEDetail } from "@/lib/types"; +import { loadTriageRecord, TRIAGE_UPDATED_EVENT } from "@/lib/triage"; + +export default function AIRemediationPlanPanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + + try { + const triage = await loadTriageRecord(cveId); + const res = await fetch(`/api/ai/remediation/${encodeURIComponent(cveId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ triage, detail }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI remediation plan"); + } + + if (!cancelled) { + setPlan(data); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load AI remediation plan"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); + return () => { + cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); + }; + }, [cveId, detail]); + + return ( +
      +
      +
      +
      + + + +
      +
      +

      AI Remediation Agent

      +

      Rollout strategy, controls, validation, and ownership guidance.

      +
      +
      + {plan?.requiresHumanApproval ? ( + + Human approval required + + ) : null} +
      + + {loading ?

      Drafting remediation plan...

      : null} + {error ?

      {error}

      : null} + + {plan && !loading ? ( +
      +
      +

      Summary

      +

      {plan.summary}

      +
      + +
      +
      + + +
      +
      +

      Strategy

      +

      {plan.strategy}

      +
      +
      +

      Ownership

      +

      {plan.ownerRationale}

      +
      +
      + + + + + +
      +

      Project Context

      +

      {plan.projectContext.summary}

      + {plan.projectContext.projectNames.length > 0 ? ( +
      + {plan.projectContext.projectNames.map((project) => ( + + ))} +
      + ) : null} +
      +
      + ) : null} +
      + ); +} + +function PlanList({ title, items }: { title: string; items: string[] }) { + return ( +
      +

      {title}

      +
        + {items.map((item) => ( +
      • + {item} +
      • + ))} +
      +
      + ); +} + +function Badge({ label, tone }: { label: string; tone: "amber" | "emerald" | "gray" }) { + const tones = { + amber: "border-amber-500/20 bg-amber-500/8 text-amber-200", + emerald: "border-emerald-500/20 bg-emerald-500/8 text-emerald-200", + gray: "border-white/[0.08] bg-white/[0.04] text-white/50", + } as const; + + return {label}; +} diff --git a/src/components/AISearchAssistantPanel.tsx b/src/components/AISearchAssistantPanel.tsx index d40a5e9..200fef6 100644 --- a/src/components/AISearchAssistantPanel.tsx +++ b/src/components/AISearchAssistantPanel.tsx @@ -1,28 +1,66 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { SearchState } from "@/lib/search"; -import { readAISettings } from "@/lib/ai-settings"; +import { createPromptTemplate, deletePromptTemplate, loadPromptTemplates, PROMPT_TEMPLATES_UPDATED_EVENT } from "@/lib/prompt-templates"; +import { AISearchInterpretation } from "@/lib/types"; +import { PromptTemplateRecord } from "@/lib/workspace-types"; interface AISearchAssistantPanelProps { onApply: (next: Partial) => void; } +const BUILT_IN_PROMPTS = [ + { + id: "builtin-openssl-critical", + name: "Critical OpenSSL This Week", + prompt: "show newly published critical CVEs affecting OpenSSL this week", + }, + { + id: "builtin-k8s-xss", + name: "Kubernetes XSS With PoC", + prompt: "show me xss vulns in k8s with proof of concept exploits from this week", + }, + { + id: "builtin-exchange-remediation", + name: "Exchange Patch First", + prompt: "what should we patch first for microsoft exchange since 2026-01-15", + }, +]; + export default function AISearchAssistantPanel({ onApply }: AISearchAssistantPanelProps) { const [prompt, setPrompt] = useState(""); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(""); + const [result, setResult] = useState(null); + const [promptTemplates, setPromptTemplates] = useState([]); + const [templateName, setTemplateName] = useState(""); + const [templateBusy, setTemplateBusy] = useState(null); + const [templateMessage, setTemplateMessage] = useState(""); + + useEffect(() => { + const sync = async () => setPromptTemplates(await loadPromptTemplates()); + void sync(); + window.addEventListener(PROMPT_TEMPLATES_UPDATED_EVENT, sync); + return () => window.removeEventListener(PROMPT_TEMPLATES_UPDATED_EVENT, sync); + }, []); + + const defaultTemplateName = useMemo(() => { + if (!prompt.trim()) return "Prompt template"; + return prompt.trim().slice(0, 48); + }, [prompt]); - async function handleInterpret() { - if (!prompt.trim()) return; + async function handleInterpret(nextPrompt = prompt) { + if (!nextPrompt.trim()) return; setLoading(true); setMessage(""); + setResult(null); try { const res = await fetch("/api/ai/search", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt, settings: readAISettings() }), + body: JSON.stringify({ prompt: nextPrompt }), }); const data = await res.json(); if (!res.ok) { @@ -30,7 +68,9 @@ export default function AISearchAssistantPanel({ onApply }: AISearchAssistantPan } onApply(data); + setPrompt(nextPrompt); setMessage(data.explanation || "Applied AI-generated filters."); + setResult(data); } catch (error) { setMessage(error instanceof Error ? error.message : "Failed to interpret search"); } finally { @@ -38,32 +78,233 @@ export default function AISearchAssistantPanel({ onApply }: AISearchAssistantPan } } + async function handleSaveTemplate() { + if (!prompt.trim()) { + setTemplateMessage("Enter a prompt before saving it as a template."); + return; + } + + setTemplateBusy("save"); + setTemplateMessage(""); + try { + const next = await createPromptTemplate(templateName || defaultTemplateName, prompt); + setPromptTemplates(next); + setTemplateName(""); + setTemplateMessage("Saved prompt template."); + } catch (error) { + setTemplateMessage(error instanceof Error ? error.message : "Failed to save prompt template"); + } finally { + setTemplateBusy(null); + } + } + + async function handleDeleteTemplate(id: string) { + setTemplateBusy(`delete:${id}`); + setTemplateMessage(""); + try { + const next = await deletePromptTemplate(id); + setPromptTemplates(next); + setTemplateMessage("Deleted prompt template."); + } catch (error) { + setTemplateMessage(error instanceof Error ? error.message : "Failed to delete prompt template"); + } finally { + setTemplateBusy(null); + } + } + return ( -
      +
      -
      -

      AI Search Assistant

      -

      Use natural language like “show me critical OpenSSL vulns from this month”.

      +
      +
      + + + +
      +
      +

      AI Search Assistant

      +

      Natural language search with aliases, CWE families, date windows, and remediation intent.

      +