From 8c1e12d6a5646fce8957cc72a6e82a9e0c2095f0 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 24 Jun 2026 14:01:43 -0400 Subject: [PATCH 01/15] Add base react app. --- frontend-rewrite-plan.md | 155 + frontend/.gitignore | 17 + frontend/.node-version | 1 + frontend/biome.json | 52 + frontend/components.json | 25 + frontend/index.html | 13 + frontend/package-lock.json | 8958 +++++++++++++++++++++++++++++++ frontend/package.json | 50 + frontend/public/favicon.svg | 11 + frontend/public/nebari-logo.svg | 24 + frontend/src/App.test.tsx | 11 + frontend/src/App.tsx | 9 + frontend/src/index.css | 9 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 17 + frontend/src/test/setup.ts | 1 + frontend/tsconfig.app.json | 31 + frontend/tsconfig.json | 10 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 42 + 20 files changed, 9466 insertions(+) create mode 100644 frontend-rewrite-plan.md create mode 100644 frontend/.gitignore create mode 100644 frontend/.node-version create mode 100644 frontend/biome.json create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/nebari-logo.svg create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md new file mode 100644 index 0000000..d230e5b --- /dev/null +++ b/frontend-rewrite-plan.md @@ -0,0 +1,155 @@ +# Frontend Rewrite Plan — React + TypeScript (issue #92) + +Rebuild the LLM Serving Pack UI from vanilla HTML/CSS/JS into a React + TypeScript +app (Vite + shadcn/ui + Tailwind v4), served as its own image, at feature parity +with the current API Key Manager. + +Tracking issue: **#92 — Rebuild the LLM Serving Pack UI with React + TypeScript** + +--- + +## Locked decisions + +- **Design system:** copy the `@theme` tokens + `ThemeProvider`/`useThemePreference` + dark-mode pattern from **nebari-landing**. No external design-system package. +- **Delivery:** serve the frontend **separately** as its own nginx image — not + embedded in the Go `key-manager` binary. +- **Location:** the app lives at the repo root in **`frontend/`** (its own component, + own Dockerfile, own CI job), consistent with `operator/`, `key-manager/`, + `model-downloader/`. +- **Stack:** React 19 + TS + Vite 7, Tailwind v4 (`@tailwindcss/vite`), + shadcn/ui + radix-ui, lucide-react, TanStack Query v5, Jotai v2, Biome 2, + Vitest 4 + Testing Library. npm, dev port 5173. + +## Reference template — nebari-landing (`~/repos/nebari-landing`) + +Same architecture (Go backend + separate `frontend/` Vite app). Copy-ready patterns: + +| Concern | File in nebari-landing | +|---|---| +| Build → nginx image, SPA `try_files` fallback | `frontend/Dockerfile` | +| `@` alias + `/api` dev proxy to `localhost:8080` | `frontend/vite.config.ts` | +| Semantic tokens, light/dark, `@custom-variant dark` | `frontend/src/app/index.css` | +| System-default theme (localStorage + `matchMedia`) | `frontend/src/hooks/useThemePreference.ts`, `ThemeContext.tsx` | + +--- + +## Current state (what we're replacing) + +The UI is a single-page **API Key Manager** embedded in the Go binary +(`//go:embed static/*` → `http.FileServer`): + +- `key-manager/internal/ui/static/{index.html, app.js, style.css, *.svg}` (~950 LOC) +- `key-manager/internal/ui/embed.go` +- static serving wired in `key-manager/cmd/main.go:110-114` + +**Backend API (unchanged by this work):** + +- `GET /api/me`, `GET /api/models`, `GET /api/keys`, `POST /api/keys`, + `DELETE /api/keys/{namespace}/{model}/{clientID}`, `/logout` +- OIDC-cookie auth middleware applied to `/api/*` only. + +**Functional surface to preserve (one page):** + +- Topbar: Nebari logo + account dropdown (name/email, Sign out → `/logout`) +- Page header + global error banner +- "My API Keys" card: Create button + table (Name/Description, Client ID, Model, + Created, kebab → Revoke); loading / empty / error states +- Dialogs: Create Key (model select + description + validation), Key Created + (client ID, one-time key, copy, download `.txt`, warning), Revoke confirmation + +--- + +## Auth constraint that shapes deployment + +The Keycloak cookie set by the NebariApp gateway is scoped to **one hostname**, so +the SPA (`/`) and the API (`/api`, `/logout`) must share a host. The `NebariApp` CRD +targets a single `service:port`. + +**Primary approach:** the **nginx frontend container is the service the NebariApp +targets**, and nginx reverse-proxies `/api/*` and `/logout` to the key-manager +ClusterIP (`:8080`). The Go key-manager drops static serving and becomes API-only. + +**Fallback** (only if confirmed supported): gateway-level path routing to two +services under one host — would let us skip the nginx `/api` proxy. + +--- + +## Phases + +### Phase 1 — Scaffold `frontend/` ✅ +- [x] Vite + React 19 + TS project at repo-root `frontend/` +- [x] Tailwind v4 via `@tailwindcss/vite`; shadcn (`components.json`) + radix-ui +- [x] Deps: lucide-react, TanStack Query v5, Jotai v2, Biome 2, Vitest 4 + Testing Library +- [x] `package.json` scripts: `dev`, `build` (`tsc -b && vite build`), `preview`, + `check` (biome), `test` / `test:run` (vitest) +- [x] `vite.config.ts` with `@` alias + `/api` (and `/logout`) dev proxy → `http://localhost:8080` +- [x] Quality gate green: `npm run build`, `npm run test:run`, `npm run check` all pass + +### Phase 2 — Theming +- [ ] Copy nebari-landing tokens into `src/app/index.css` (light/dark, `@custom-variant dark`) +- [ ] Copy `useThemePreference.ts` + `ThemeContext.tsx` (+ `useLocalStorageState`); system default +- [ ] Align `components.json` baseColor/aliases to the tokens + +### Phase 3 — Data + state layer +- [ ] `lib/api.ts` fetch wrapper (JSON, error messages, 204 → null) +- [ ] TanStack Query hooks: `useCurrentUser`, `useModels`, `useKeys`, `useCreateKey`, `useRevokeKey` +- [ ] `store/` Jotai atoms for dialog open/close + pending-revoke (server state stays in Query) +- [ ] `QueryClientProvider` + `ThemeProvider` wired in `main.tsx` + +### Phase 4 — Components (feature parity) +Each component gets its own PascalCase dir + co-located test + `index.ts` barrel. +- [ ] `Topbar` (logo, account dropdown: name/email, theme submenu light/dark/system, Sign out → `/logout`) +- [ ] `KeysCard` (table + loading/empty/error states + Create button) +- [ ] `KeyRowActions` (kebab → Revoke) +- [ ] `CreateKeyDialog` (model select + description + validation) +- [ ] `KeyCreatedDialog` (client ID, one-time key, copy, download `.txt`, warning) +- [ ] `RevokeKeyDialog` (destructive confirm) +- [ ] `ErrorBanner` / toast +- [ ] shadcn primitives added: button, dialog, table, select, input, label, + dropdown-menu, alert, card, avatar, sonner + +### Phase 5 — Serve separately (Docker + Helm + CI) +- [ ] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` + + `/logout` `proxy_pass` to key-manager service) +- [ ] `frontend/nginx.conf` +- [ ] Gut `key-manager/internal/ui/` (remove `embed.go` + `static/`); drop file + server / SPA route from `cmd/main.go` → key-manager becomes API-only +- [ ] Helm: new `frontend` Deployment + Service (nginx :80, backend URL via env) +- [ ] Repoint `key-manager-nebariapp.yaml` `service:` at the frontend service +- [ ] Add `frontend.image.*` to `values.yaml` +- [ ] CI: `build-frontend` job in `build-images.yaml` +- [ ] CI: `lint-frontend` + test job (biome + vitest) in `lint.yaml` / `test.yaml` + +### Phase 6 — Local dev +- [ ] Wire `frontend/` into `dev/` (Makefile / manifests) alongside the mock backend +- [ ] Document Vite `/api` proxy + how auth is faked/bypassed locally (cookie middleware + needs a dev shim or a running gateway) + +### Phase 7 — Quality gate, cleanup, docs +- [ ] `npm run build && npm run test:run && npm run check` all pass +- [ ] Remove old vanilla files only after parity confirmed +- [ ] Refresh `docs/install-production-screenshots/*` + README / getting-started UI references + +--- + +## Open items to confirm during build + +- [ ] Can `NebariApp` route two services under one host? (Would enable the fallback + and let us drop the nginx `/api` proxy.) Default assumes **no**. +- [ ] Confirm the key-manager ClusterIP service name/port the nginx proxy targets. + +--- + +## Acceptance criteria (from #92) + +- [ ] Vite + React + TS scaffold per `frontend-dev` conventions +- [ ] Tailwind v4 wired to Nebari design-system tokens (semantic, not hardcoded) +- [ ] shadcn/ui via `components.json`; UI composed on design-system components +- [ ] All existing screens reimplemented at feature parity +- [ ] State/data fetching per conventions (TanStack Query + Jotai where applicable) +- [ ] UI matches Figma + uses shared design-system components +- [ ] Dark-mode via profile menu, defaulting to System +- [ ] Biome + Vitest gates pass (`biome check`, `vitest run`) +- [ ] Build/dev/lint/test scripts in `package.json` +- [ ] Old vanilla frontend removed once parity confirmed diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..d73112e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,17 @@ +# Logs +*.log +npm-debug.log* + +node_modules +dist +dist-ssr +*.local + +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.sw? diff --git a/frontend/.node-version b/frontend/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/frontend/.node-version @@ -0,0 +1 @@ +22 diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..08dd1f0 --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + + "files": { + "includes": [ + "src/**", + "biome.json", + "package.json", + "tsconfig*.json", + "vite.config.ts", + "!!**/dist" + ] + }, + + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUndeclaredVariables": "error" + } + } + }, + + "css": { + "parser": { + "tailwindDirectives": true + } + }, + + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..dfe0f2d --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-vega", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8b7096a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Local AI Model API Key Manager + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5dc97b2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8958 @@ +{ + "name": "nebari-llm-serving-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nebari-llm-serving-frontend", + "version": "0.0.0", + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jotai": "^2.10.0", + "lucide-react": "^0.460.0", + "radix-ui": "^1.5.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "shadcn": "^4.10.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@biomejs/biome": "2.4.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "jsdom": "^29.1.1", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.8" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", + "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.75.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", + "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@dotenvx/primitives": "^0.8.0", + "commander": "^11.1.0", + "conf": "^10.2.0", + "dotenv": "^17.2.1", + "enquirer": "^2.4.1", + "env-paths": "^2.2.1", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "open": "^8.4.2", + "picomatch": "^4.0.4", + "systeminformation": "^5.22.11", + "undici": "^7.11.0", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/primitives": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-0.8.0.tgz", + "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "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/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.10.tgz", + "integrity": "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "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.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", + "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "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-alert-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz", + "integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-primitive": "2.1.6" + }, + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.10.tgz", + "integrity": "sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "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.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.0.tgz", + "integrity": "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.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-checkbox": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz", + "integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.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-collapsible": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", + "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.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-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.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-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "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.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "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.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.1.tgz", + "integrity": "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "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-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "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.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.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-dropdown-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "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-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.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-form": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.10.tgz", + "integrity": "sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.10", + "@radix-ui/react-primitive": "2.1.6" + }, + "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.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.17.tgz", + "integrity": "sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "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-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-label": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz", + "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "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.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-menubar": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.18.tgz", + "integrity": "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "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-navigation-menu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", + "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.10.tgz", + "integrity": "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.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-password-toggle-field": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.5.tgz", + "integrity": "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.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-popover": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.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-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.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-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.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-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.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-progress": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.10.tgz", + "integrity": "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6" + }, + "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.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.1.tgz", + "integrity": "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.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-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "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-scroll-area": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", + "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.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-select": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", + "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-separator": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", + "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "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.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.1.tgz", + "integrity": "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.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-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "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.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.1.tgz", + "integrity": "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.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-tabs": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "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-toast": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.17.tgz", + "integrity": "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "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.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.12.tgz", + "integrity": "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "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-group": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.13.tgz", + "integrity": "sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-use-controllable-state": "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-toolbar": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.13.tgz", + "integrity": "sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-separator": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.13" + }, + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", + "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "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.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "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.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "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-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "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-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "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-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "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-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "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-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "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-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-visually-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "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/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz", + "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz", + "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "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==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "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.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "license": "MIT", + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/conf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", + "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "license": "MIT", + "dependencies": { + "ajv": "^8.6.3", + "ajv-formats": "^2.1.1", + "atomically": "^1.7.0", + "debounce-fn": "^4.0.0", + "dot-prop": "^6.0.1", + "env-paths": "^2.2.1", + "json-schema-typed": "^7.0.3", + "onetime": "^5.1.2", + "pkg-up": "^3.1.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/conf/node_modules/json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "license": "BSD-2-Clause" + }, + "node_modules/conf/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "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/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jotai": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.20.1.tgz", + "integrity": "sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0", + "@babel/template": ">=7.0.0", + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.49", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", + "integrity": "sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/radix-ui": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.0.tgz", + "integrity": "sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-accessible-icon": "1.1.10", + "@radix-ui/react-accordion": "1.2.14", + "@radix-ui/react-alert-dialog": "1.1.17", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-aspect-ratio": "1.1.10", + "@radix-ui/react-avatar": "1.2.0", + "@radix-ui/react-checkbox": "1.3.5", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context-menu": "2.3.1", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dropdown-menu": "2.1.18", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-form": "0.1.10", + "@radix-ui/react-hover-card": "1.1.17", + "@radix-ui/react-label": "2.1.10", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-menubar": "1.1.18", + "@radix-ui/react-navigation-menu": "1.2.16", + "@radix-ui/react-one-time-password-field": "0.1.10", + "@radix-ui/react-password-toggle-field": "0.1.5", + "@radix-ui/react-popover": "1.1.17", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-progress": "1.1.10", + "@radix-ui/react-radio-group": "1.4.1", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-scroll-area": "1.2.12", + "@radix-ui/react-select": "2.3.1", + "@radix-ui/react-separator": "1.1.10", + "@radix-ui/react-slider": "1.4.1", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.1", + "@radix-ui/react-tabs": "1.1.15", + "@radix-ui/react-toast": "1.2.17", + "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-toggle-group": "1.1.13", + "@radix-ui/react-toolbar": "1.1.13", + "@radix-ui/react-tooltip": "1.2.10", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "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/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.11.0.tgz", + "integrity": "sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "https-proxy-agent": "^7.0.6", + "kleur": "^4.1.5", + "node-fetch": "^3.3.2", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "tailwind-merge": "^3.0.1", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/systeminformation": { + "version": "5.31.10", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.10.tgz", + "integrity": "sha512-EiBvQfFxZMkNNcfStjxgyy4dxAH+lzaqon9DXiIv6yHBrrdFGChDN1csbJJCBtaM1LWfuik2ZueeeQbRS/1wcA==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-spinner": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", + "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7553957 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "nebari-llm-serving-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "check": "biome check", + "check:fix": "biome check --write", + "lint": "biome lint", + "lint:fix": "biome lint --write", + "format": "biome format", + "format:write": "biome format --write", + "ci": "biome ci", + "test": "vitest --run" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jotai": "^2.10.0", + "lucide-react": "^0.460.0", + "radix-ui": "^1.5.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "shadcn": "^4.10.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@biomejs/biome": "2.4.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "jsdom": "^29.1.1", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.8" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9e55146 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/nebari-logo.svg b/frontend/public/nebari-logo.svg new file mode 100644 index 0000000..0ab6aab --- /dev/null +++ b/frontend/public/nebari-logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..f60bb9d --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import App from "./App"; + +describe("App", () => { + it("renders the app shell", () => { + render(); + expect(screen.getByText(/Local AI Model API Key Manager/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..32c3328 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,9 @@ +export default function App() { + return ( +
+

+ Local AI Model API Key Manager — scaffolding in progress. +

+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..cee7289 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/inter"; + +@custom-variant dark (&:is(.dark *)); + +/* Design-system tokens (semantic, light + dark) are added in Phase 2, + copied from the nebari-landing repo. */ diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5739186 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App.tsx"; + +import "./index.css"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +createRoot(rootElement).render( + + + , +); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..5003314 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client", "vitest/globals", "node", "@testing-library/jest-dom"], + "skipLibCheck": true, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3c92754 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "files": [], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..a96b3e5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4544669 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,42 @@ +/// + +import path from "node:path"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + // In local dev Vite stands in for the production nginx layer and proxies the + // backend routes to the key-manager API. Override with WEBAPI_URL to point at + // an in-cluster ClusterIP instead of a locally running key-manager. + const apiTarget = env.WEBAPI_URL ?? "http://localhost:8080"; + + return { + // The Tailwind plugin is required for Tailwind v4 utilities and shadcn + // component styles to compile in dev and build. + plugins: [react(), tailwindcss()], + + resolve: { + alias: { + // shadcn emits imports like "@/components" and "@/lib/utils". + "@": path.resolve(__dirname, "./src"), + }, + }, + + server: { + proxy: { + "/api": { target: apiTarget, changeOrigin: true }, + "/logout": { target: apiTarget, changeOrigin: true }, + }, + }, + + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test/setup.ts", + css: true, + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, + }; +}); From d62cfd7b3bce2d3f91173640c1ef4166c7423f86 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 24 Jun 2026 14:06:11 -0400 Subject: [PATCH 02/15] Apply base theming. --- frontend-rewrite-plan.md | 11 +- frontend/src/hooks/useLocalStorageState.ts | 45 ++++ frontend/src/hooks/useThemePreference.test.ts | 32 +++ frontend/src/hooks/useThemePreference.ts | 62 ++++++ frontend/src/index.css | 203 +++++++++++++++++- frontend/src/main.tsx | 6 +- .../providers/ThemeProvider/ThemeProvider.tsx | 24 +++ frontend/src/providers/ThemeProvider/index.ts | 1 + frontend/src/test/setup.ts | 47 ++++ 9 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/useLocalStorageState.ts create mode 100644 frontend/src/hooks/useThemePreference.test.ts create mode 100644 frontend/src/hooks/useThemePreference.ts create mode 100644 frontend/src/providers/ThemeProvider/ThemeProvider.tsx create mode 100644 frontend/src/providers/ThemeProvider/index.ts diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index d230e5b..5fa144e 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -86,10 +86,13 @@ services under one host — would let us skip the nginx `/api` proxy. - [x] `vite.config.ts` with `@` alias + `/api` (and `/logout`) dev proxy → `http://localhost:8080` - [x] Quality gate green: `npm run build`, `npm run test:run`, `npm run check` all pass -### Phase 2 — Theming -- [ ] Copy nebari-landing tokens into `src/app/index.css` (light/dark, `@custom-variant dark`) -- [ ] Copy `useThemePreference.ts` + `ThemeContext.tsx` (+ `useLocalStorageState`); system default -- [ ] Align `components.json` baseColor/aliases to the tokens +### Phase 2 — Theming ✅ +- [x] Copy nebari-landing tokens into `src/index.css` (light/dark, `@theme inline`, `@custom-variant dark`) +- [x] Copy `useThemePreference.ts` + `useLocalStorageState.ts`; add `ThemeProvider` (`src/providers/ThemeProvider/`); system default +- [x] `ThemeProvider` wired into `main.tsx` +- [x] `components.json` baseColor/aliases aligned (style `radix-vega`, css `src/index.css`) +- [x] Test setup mocks (`localStorage`, `matchMedia`) so theme hooks test cleanly; gate green + - Note: `npm test` runs vitest once (`vitest --run`); no separate `test:run`. ### Phase 3 — Data + state layer - [ ] `lib/api.ts` fetch wrapper (JSON, error messages, 204 → null) diff --git a/frontend/src/hooks/useLocalStorageState.ts b/frontend/src/hooks/useLocalStorageState.ts new file mode 100644 index 0000000..104a23b --- /dev/null +++ b/frontend/src/hooks/useLocalStorageState.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; + +/** + * Read a value from localStorage, returning null when storage is unavailable + * (private browsing, disabled, security errors) instead of throwing. + */ +export function getStoredValue(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** + * Write a value to localStorage, swallowing errors when storage is unavailable + * or the quota is exceeded. + */ +export function setStoredValue(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + console.error(`Failed to persist "${key}" to localStorage`); + } +} + +/** + * State persisted to localStorage as a plain string. Reads and writes are + * guarded so disabled or unavailable storage never throws. + * + * `deserialize` turns the raw stored string (or null when absent) into the + * initial value — also the place to validate and run one-off migrations. + */ +export function useLocalStorageState( + key: string, + deserialize: (raw: string | null) => T, +): [T, (value: T) => void] { + const [value, setValue] = useState(() => deserialize(getStoredValue(key))); + + useEffect(() => { + setStoredValue(key, value); + }, [key, value]); + + return [value, setValue]; +} diff --git a/frontend/src/hooks/useThemePreference.test.ts b/frontend/src/hooks/useThemePreference.test.ts new file mode 100644 index 0000000..8b370e9 --- /dev/null +++ b/frontend/src/hooks/useThemePreference.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useThemePreference } from "./useThemePreference"; + +afterEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); + +describe("useThemePreference", () => { + it("defaults to system mode", () => { + const { result } = renderHook(() => useThemePreference()); + expect(result.current.themeMode).toBe("system"); + }); + + it("toggles the dark class when set to dark", () => { + const { result } = renderHook(() => useThemePreference()); + + act(() => result.current.setThemeMode("dark")); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + act(() => result.current.setThemeMode("light")); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + it("persists the selected mode", () => { + const { result } = renderHook(() => useThemePreference()); + act(() => result.current.setThemeMode("dark")); + expect(localStorage.getItem("nebari-llm:themeMode")).toBe("dark"); + }); +}); diff --git a/frontend/src/hooks/useThemePreference.ts b/frontend/src/hooks/useThemePreference.ts new file mode 100644 index 0000000..fe5a7a3 --- /dev/null +++ b/frontend/src/hooks/useThemePreference.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; + +import { useLocalStorageState } from "./useLocalStorageState"; + +export const THEME_MODES = ["light", "dark", "system"] as const; +export type ThemeMode = (typeof THEME_MODES)[number]; + +export function isThemeMode(value: string): value is ThemeMode { + return (THEME_MODES as readonly string[]).includes(value); +} + +const THEME_MODE_STORAGE_KEY = "nebari-llm:themeMode"; + +function prefersDark(): boolean { + try { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch { + return false; + } +} + +function readStoredMode(raw: string | null): ThemeMode { + if (raw !== null && isThemeMode(raw)) { + return raw; + } + return "system"; +} + +/** + * Tracks the user's theme preference (light / dark / system), persists it, and + * toggles the `dark` class on so Tailwind's dark variant applies. + * Defaults to "system" and stays in sync with the OS preference. + */ +export function useThemePreference() { + const [themeMode, setThemeMode] = useLocalStorageState( + THEME_MODE_STORAGE_KEY, + readStoredMode, + ); + const [systemPrefersDark, setSystemPrefersDark] = useState(prefersDark); + + // Keep "system" mode in sync with the OS preference as it changes. + useEffect(() => { + let mediaQuery: MediaQueryList; + try { + mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + } catch { + return; + } + + const onChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches); + mediaQuery.addEventListener("change", onChange); + return () => mediaQuery.removeEventListener("change", onChange); + }, []); + + const isDarkMode = themeMode === "system" ? systemPrefersDark : themeMode === "dark"; + + useEffect(() => { + document.documentElement.classList.toggle("dark", isDarkMode); + }, [isDarkMode]); + + return { themeMode, isDarkMode, setThemeMode }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index cee7289..1175d9f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,5 +5,204 @@ @custom-variant dark (&:is(.dark *)); -/* Design-system tokens (semantic, light + dark) are added in Phase 2, - copied from the nebari-landing repo. */ +/* Design-system tokens copied from the nebari-landing repo so the serving-pack + UI shares one source of truth for color, radius, and status styling. Keep + these in sync with nebari-landing rather than hand-tuning values here. */ + +:root { + --background: #ffffff; + --foreground: #1b1b1b; + + --card: #ffffff; + --card-foreground: #1b1b1b; + + --popover: #ffffff; + --popover-foreground: #1b1b1b; + + --primary: #9b3dcc; + --primary-foreground: #ffffff; + + --secondary: #f3f5f6; + --secondary-foreground: #1b1b1b; + + --muted: #f3f5f6; + --muted-foreground: #65748a; + + --accent: #e7eaee; + --accent-foreground: #1b1b1b; + + --pill-category-fg: #475467; + + --destructive: #d92d20; + + --border: #d0d5dd; + --input: #d0d5dd; + --ring: var(--primary); + + --chart-1: #98a2b3; + --chart-2: #667085; + --chart-3: #475467; + --chart-4: #344054; + --chart-5: #1d2939; + + --radius: 0.625rem; + + --text-secondary: #4e596a; + + --sidebar: #ffffff; + --sidebar-foreground: #1b1b1b; + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: #f3f5f6; + --sidebar-accent-foreground: #1b1b1b; + --sidebar-border: #d0d5dd; + --sidebar-ring: var(--primary); + + --status-healthy-bg: #10b9811a; + --status-healthy-fg: #047857; + --status-healthy-dot: #10b981; + + --status-unhealthy-bg: #ef44441a; + --status-unhealthy-fg: #b42318; + --status-unhealthy-dot: #ef4444; + + --status-default-bg: var(--muted); + --status-default-fg: #475467; + --status-default-dot: #475467; +} + +.dark { + --background: #111418; + --foreground: #f3f5f6; + + --card: #111418; + --card-foreground: #f3f5f6; + + --popover: #111418; + --popover-foreground: #f3f5f6; + + --primary: #9b3dcc; + --primary-foreground: #ffffff; + + --secondary: #22282f; + --secondary-foreground: #f3f5f6; + + --muted: #22282f; + /* Lightened from #65748a to meet WCAG AA (4.5:1) for secondary text in dark + mode, including the theme toggle labels rendered on the muted background. */ + --muted-foreground: #909db0; + + --accent: #3d4553; + --accent-foreground: #f3f5f6; + + --pill-category-fg: #ffffff; + + --destructive: #f04438; + + --border: #3d4553; + --input: #3d4553; + --ring: var(--primary); + + --chart-1: #b7c0cc; + --chart-2: #98a2b3; + --chart-3: #667085; + --chart-4: #475467; + --chart-5: #344054; + + --text-secondary: #d0d5dd; + + --sidebar: #111418; + --sidebar-foreground: #f3f5f6; + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: #22282f; + --sidebar-accent-foreground: #f3f5f6; + --sidebar-border: #3d4553; + --sidebar-ring: var(--primary); + --status-healthy-bg: #10b9811a; + --status-healthy-fg: #6ee7b7; + --status-healthy-dot: #34d399; + + --status-unhealthy-bg: #ef44441a; + --status-unhealthy-fg: #fda29b; + --status-unhealthy-dot: #f97066; + + --status-default-bg: var(--muted); + --status-default-fg: #f3f5f6; + --status-default-dot: #f3f5f6; +} + +@theme inline { + --font-sans: "Inter Variable", sans-serif; + + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + + --color-foreground: var(--foreground); + --color-background: var(--background); + + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html { + @apply font-sans; + } + + body { + @apply bg-background text-foreground; + } + + html, + body, + #root { + width: 100%; + margin: 0; + min-height: 100%; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5739186..7f6ca2b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { ThemeProvider } from "@/providers/ThemeProvider"; + import App from "./App.tsx"; import "./index.css"; @@ -12,6 +14,8 @@ if (!rootElement) { createRoot(rootElement).render( - + + + , ); diff --git a/frontend/src/providers/ThemeProvider/ThemeProvider.tsx b/frontend/src/providers/ThemeProvider/ThemeProvider.tsx new file mode 100644 index 0000000..4021910 --- /dev/null +++ b/frontend/src/providers/ThemeProvider/ThemeProvider.tsx @@ -0,0 +1,24 @@ +import { createContext, type ReactNode, useContext } from "react"; + +import { type ThemeMode, useThemePreference } from "@/hooks/useThemePreference"; + +type ThemeContextValue = { + themeMode: ThemeMode; + isDarkMode: boolean; + setThemeMode: (mode: ThemeMode) => void; +}; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const theme = useThemePreference(); + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within ThemeProvider"); + } + return ctx; +} diff --git a/frontend/src/providers/ThemeProvider/index.ts b/frontend/src/providers/ThemeProvider/index.ts new file mode 100644 index 0000000..730dd8f --- /dev/null +++ b/frontend/src/providers/ThemeProvider/index.ts @@ -0,0 +1 @@ +export { ThemeProvider, useTheme } from "./ThemeProvider"; diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index f149f27..8b8924c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1 +1,48 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom under this vitest version does not expose a functional Web Storage on +// the default origin, and never implements matchMedia. Provide deterministic +// in-memory stand-ins so hooks that read theme preference work under test. + +class MemoryStorage implements Storage { + private store = new Map(); + get length() { + return this.store.size; + } + clear() { + this.store.clear(); + } + getItem(key: string) { + return this.store.has(key) ? (this.store.get(key) as string) : null; + } + key(index: number) { + return Array.from(this.store.keys())[index] ?? null; + } + removeItem(key: string) { + this.store.delete(key); + } + setItem(key: string, value: string) { + this.store.set(key, String(value)); + } +} + +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: new MemoryStorage(), +}); + +if (typeof window.matchMedia !== "function") { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }), + }); +} From a8807b6249188db976782daf846944b4d394127b Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 24 Jun 2026 14:09:50 -0400 Subject: [PATCH 03/15] Add data and state layer. --- frontend-rewrite-plan.md | 14 +++--- frontend/src/hooks/useApiKeys.test.tsx | 59 ++++++++++++++++++++++++++ frontend/src/hooks/useApiKeys.ts | 44 +++++++++++++++++++ frontend/src/hooks/useCurrentUser.ts | 14 ++++++ frontend/src/hooks/useModels.ts | 20 +++++++++ frontend/src/lib/api.test.ts | 51 ++++++++++++++++++++++ frontend/src/lib/api.ts | 37 ++++++++++++++++ frontend/src/lib/queryClient.ts | 13 ++++++ frontend/src/lib/types.ts | 53 +++++++++++++++++++++++ frontend/src/main.tsx | 10 +++-- frontend/src/store/dialogAtoms.ts | 15 +++++++ 11 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 frontend/src/hooks/useApiKeys.test.tsx create mode 100644 frontend/src/hooks/useApiKeys.ts create mode 100644 frontend/src/hooks/useCurrentUser.ts create mode 100644 frontend/src/hooks/useModels.ts create mode 100644 frontend/src/lib/api.test.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/queryClient.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/store/dialogAtoms.ts diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index 5fa144e..c50de55 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -94,11 +94,15 @@ services under one host — would let us skip the nginx `/api` proxy. - [x] Test setup mocks (`localStorage`, `matchMedia`) so theme hooks test cleanly; gate green - Note: `npm test` runs vitest once (`vitest --run`); no separate `test:run`. -### Phase 3 — Data + state layer -- [ ] `lib/api.ts` fetch wrapper (JSON, error messages, 204 → null) -- [ ] TanStack Query hooks: `useCurrentUser`, `useModels`, `useKeys`, `useCreateKey`, `useRevokeKey` -- [ ] `store/` Jotai atoms for dialog open/close + pending-revoke (server state stays in Query) -- [ ] `QueryClientProvider` + `ThemeProvider` wired in `main.tsx` +### Phase 3 — Data + state layer ✅ +- [x] `lib/api.ts` fetch wrapper (`api.get/post/delete`, `ApiError` w/ status, JSON, 204 → null) +- [x] `lib/types.ts` — API shapes; `RawModelInfo` (PascalCase, no Go json tags) normalized to `Model` +- [x] `lib/queryClient.ts` — configured `QueryClient` (retry off, 30s staleTime) +- [x] TanStack Query hooks: `useCurrentUser`, `useModels`, `useApiKeys` + `useCreateKey` + `useRevokeKey` + (mutations invalidate `["keys"]`) +- [x] `store/dialogAtoms.ts` — Jotai discriminated-union dialog atom (none/create/created/revoke) +- [x] `QueryClientProvider` + `ThemeProvider` wired in `main.tsx` +- [x] Tests: `api.test.ts`, `useApiKeys.test.tsx`; gate green (11 tests) ### Phase 4 — Components (feature parity) Each component gets its own PascalCase dir + co-located test + `index.ts` barrel. diff --git a/frontend/src/hooks/useApiKeys.test.tsx b/frontend/src/hooks/useApiKeys.test.tsx new file mode 100644 index 0000000..7b43595 --- /dev/null +++ b/frontend/src/hooks/useApiKeys.test.tsx @@ -0,0 +1,59 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useApiKeys } from "./useApiKeys"; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useApiKeys", () => { + it("returns the keys array from the response envelope", async () => { + const keys = [ + { + clientId: "user-jane-1", + creator: "jane", + description: "Notebook", + createdAt: "2026-01-01T00:00:00Z", + modelName: "llama", + namespace: "ns", + }, + ]; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ keys }), + text: async () => "", + } as unknown as Response), + ); + + const { result } = renderHook(() => useApiKeys(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(keys); + }); + + it("coerces a null keys envelope to an empty array", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ keys: null }), + text: async () => "", + } as unknown as Response), + ); + + const { result } = renderHook(() => useApiKeys(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/frontend/src/hooks/useApiKeys.ts b/frontend/src/hooks/useApiKeys.ts new file mode 100644 index 0000000..e144b21 --- /dev/null +++ b/frontend/src/hooks/useApiKeys.ts @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; +import type { ApiKey, CreateKeyInput, CreateKeyResult } from "@/lib/types"; + +export const apiKeysQueryKey = ["keys"] as const; + +/** The current user's API keys (GET /api/keys). */ +export function useApiKeys() { + return useQuery({ + queryKey: apiKeysQueryKey, + queryFn: async () => { + const data = await api.get<{ keys: ApiKey[] | null }>("/api/keys"); + return data.keys ?? []; + }, + }); +} + +/** Create a new API key (POST /api/keys). */ +export function useCreateKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateKeyInput) => api.post("/api/keys", input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: apiKeysQueryKey }); + }, + }); +} + +/** Revoke an API key (DELETE /api/keys/{namespace}/{model}/{clientId}). */ +export function useRevokeKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (key: Pick) => + api.delete( + `/api/keys/${encodeURIComponent(key.namespace)}/${encodeURIComponent( + key.modelName, + )}/${encodeURIComponent(key.clientId)}`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: apiKeysQueryKey }); + }, + }); +} diff --git a/frontend/src/hooks/useCurrentUser.ts b/frontend/src/hooks/useCurrentUser.ts new file mode 100644 index 0000000..50ec187 --- /dev/null +++ b/frontend/src/hooks/useCurrentUser.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; +import type { CurrentUser } from "@/lib/types"; + +export const currentUserQueryKey = ["me"] as const; + +/** The authenticated user (GET /api/me). */ +export function useCurrentUser() { + return useQuery({ + queryKey: currentUserQueryKey, + queryFn: () => api.get("/api/me"), + }); +} diff --git a/frontend/src/hooks/useModels.ts b/frontend/src/hooks/useModels.ts new file mode 100644 index 0000000..9ad2ef0 --- /dev/null +++ b/frontend/src/hooks/useModels.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; +import type { Model, RawModelInfo } from "@/lib/types"; + +export const modelsQueryKey = ["models"] as const; + +/** + * Models the user may create keys for (GET /api/models), normalized from the + * PascalCase Go struct shape into the camelCase {@link Model} the UI uses. + */ +export function useModels() { + return useQuery({ + queryKey: modelsQueryKey, + queryFn: async (): Promise => { + const data = await api.get<{ models: RawModelInfo[] | null }>("/api/models"); + return (data.models ?? []).map((m) => ({ name: m.Name, namespace: m.Namespace })); + }, + }); +} diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..ab35992 --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ApiError, api } from "./api"; + +function mockFetch(response: Partial & { jsonBody?: unknown; textBody?: string }) { + const resp = { + ok: response.ok ?? true, + status: response.status ?? 200, + json: async () => response.jsonBody, + text: async () => response.textBody ?? "", + } as unknown as Response; + return vi.fn().mockResolvedValue(resp); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("api", () => { + it("parses a JSON body on success", async () => { + vi.stubGlobal("fetch", mockFetch({ jsonBody: { hello: "world" } })); + await expect(api.get<{ hello: string }>("/api/x")).resolves.toEqual({ hello: "world" }); + }); + + it("returns null for 204 responses", async () => { + vi.stubGlobal("fetch", mockFetch({ status: 204 })); + await expect(api.delete("/api/x")).resolves.toBeNull(); + }); + + it("throws ApiError with status on a non-2xx response", async () => { + vi.stubGlobal("fetch", mockFetch({ ok: false, status: 403, textBody: "forbidden" })); + await expect(api.post("/api/x", {})).rejects.toMatchObject({ + name: "ApiError", + status: 403, + }); + }); + + it("serializes the request body as JSON", async () => { + const fetchMock = mockFetch({ jsonBody: {} }); + vi.stubGlobal("fetch", fetchMock); + await api.post("/api/x", { a: 1 }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/x", + expect.objectContaining({ method: "POST", body: JSON.stringify({ a: 1 }) }), + ); + }); + + it("exposes ApiError as an Error subclass", () => { + expect(new ApiError(500, "boom")).toBeInstanceOf(Error); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..9e8c546 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,37 @@ +/** Error thrown for any non-2xx response, carrying the HTTP status. */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const opts: RequestInit = { + method, + headers: { "Content-Type": "application/json" }, + }; + if (body !== undefined) { + opts.body = JSON.stringify(body); + } + + const resp = await fetch(path, opts); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new ApiError(resp.status, `${method} ${path} failed (${resp.status}): ${text.trim()}`); + } + // 204 No Content (e.g. DELETE) has no body to parse. + if (resp.status === 204) { + return null as T; + } + return (await resp.json()) as T; +} + +export const api = { + get: (path: string) => request("GET", path), + post: (path: string, body?: unknown) => request("POST", path, body), + delete: (path: string) => request("DELETE", path), +}; diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..ee925b2 --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,13 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Auth/data here is short-lived and cheap to refetch; avoid masking real + // failures (e.g. an expired session) behind silent retries. + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..1caa52c --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,53 @@ +// Shapes returned by the key-manager API (key-manager/internal/api/handler.go). + +/** GET /api/me */ +export interface CurrentUser { + username: string; + name: string; + email: string; + groups: string[]; +} + +/** + * Raw entry from GET /api/models. The Go `ModelInfo` struct carries no JSON + * tags, so fields serialize with their PascalCase Go names. `useModels` + * normalizes this into {@link Model}; prefer that everywhere else. + */ +export interface RawModelInfo { + Name: string; + Namespace: string; + ModelName: string; + Public: boolean; + Groups: string[] | null; + Passthrough: boolean; + Provider: string; +} + +/** Normalized model the UI works with. */ +export interface Model { + /** LLMModel name — the value POSTed as `modelName` when creating a key. */ + name: string; + namespace: string; +} + +/** GET /api/keys */ +export interface ApiKey { + clientId: string; + creator: string; + description: string; + createdAt: string; + modelName: string; + namespace: string; +} + +/** POST /api/keys request body */ +export interface CreateKeyInput { + modelName: string; + description: string; +} + +/** POST /api/keys response */ +export interface CreateKeyResult { + clientId: string; + apiKey: string; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7f6ca2b..137621b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,8 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { queryClient } from "@/lib/queryClient"; import { ThemeProvider } from "@/providers/ThemeProvider"; import App from "./App.tsx"; @@ -14,8 +16,10 @@ if (!rootElement) { createRoot(rootElement).render( - - - + + + + + , ); diff --git a/frontend/src/store/dialogAtoms.ts b/frontend/src/store/dialogAtoms.ts new file mode 100644 index 0000000..3566855 --- /dev/null +++ b/frontend/src/store/dialogAtoms.ts @@ -0,0 +1,15 @@ +import { atom } from "jotai"; + +import type { ApiKey } from "@/lib/types"; + +/** + * Which modal dialog is currently open, plus the data it needs. Modeled as a + * single discriminated union so only one dialog can be open at a time. + */ +export type DialogState = + | { type: "none" } + | { type: "create" } + | { type: "created"; clientId: string; apiKey: string } + | { type: "revoke"; key: ApiKey }; + +export const dialogAtom = atom({ type: "none" }); From 9d6e8a6b8e6288e4f4e09f4cad0c6aa5a804ea2d Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 24 Jun 2026 14:37:25 -0400 Subject: [PATCH 04/15] Add ui components and setup base ui. --- frontend-rewrite-plan.md | 27 +- frontend/index.html | 2 +- frontend/src/App.test.tsx | 35 ++- frontend/src/App.tsx | 33 ++- .../CreateKeyDialog/CreateKeyDialog.tsx | 112 ++++++++ .../src/components/CreateKeyDialog/index.ts | 1 + .../components/ErrorBanner/ErrorBanner.tsx | 31 +++ frontend/src/components/ErrorBanner/index.ts | 1 + .../KeyCreatedDialog/KeyCreatedDialog.tsx | 107 ++++++++ .../src/components/KeyCreatedDialog/index.ts | 1 + .../KeyRowActions/KeyRowActions.tsx | 37 +++ .../src/components/KeyRowActions/index.ts | 1 + .../src/components/KeysCard/KeysCard.test.tsx | 55 ++++ frontend/src/components/KeysCard/KeysCard.tsx | 74 ++++++ frontend/src/components/KeysCard/index.ts | 1 + .../RevokeKeyDialog/RevokeKeyDialog.tsx | 69 +++++ .../src/components/RevokeKeyDialog/index.ts | 1 + frontend/src/components/Topbar/Topbar.tsx | 117 +++++++++ frontend/src/components/Topbar/index.ts | 1 + frontend/src/components/ui/alert.tsx | 73 ++++++ frontend/src/components/ui/avatar.tsx | 94 +++++++ frontend/src/components/ui/badge.tsx | 45 ++++ frontend/src/components/ui/button.tsx | 67 +++++ frontend/src/components/ui/card.tsx | 92 +++++++ frontend/src/components/ui/dialog.tsx | 139 ++++++++++ frontend/src/components/ui/dropdown-menu.tsx | 247 ++++++++++++++++++ frontend/src/components/ui/input.tsx | 19 ++ frontend/src/components/ui/label.tsx | 21 ++ frontend/src/components/ui/select.tsx | 182 +++++++++++++ frontend/src/components/ui/table.tsx | 84 ++++++ frontend/src/lib/format.ts | 25 ++ frontend/src/store/appAtoms.ts | 4 + frontend/src/test/render.tsx | 26 ++ 33 files changed, 1802 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx create mode 100644 frontend/src/components/CreateKeyDialog/index.ts create mode 100644 frontend/src/components/ErrorBanner/ErrorBanner.tsx create mode 100644 frontend/src/components/ErrorBanner/index.ts create mode 100644 frontend/src/components/KeyCreatedDialog/KeyCreatedDialog.tsx create mode 100644 frontend/src/components/KeyCreatedDialog/index.ts create mode 100644 frontend/src/components/KeyRowActions/KeyRowActions.tsx create mode 100644 frontend/src/components/KeyRowActions/index.ts create mode 100644 frontend/src/components/KeysCard/KeysCard.test.tsx create mode 100644 frontend/src/components/KeysCard/KeysCard.tsx create mode 100644 frontend/src/components/KeysCard/index.ts create mode 100644 frontend/src/components/RevokeKeyDialog/RevokeKeyDialog.tsx create mode 100644 frontend/src/components/RevokeKeyDialog/index.ts create mode 100644 frontend/src/components/Topbar/Topbar.tsx create mode 100644 frontend/src/components/Topbar/index.ts create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/store/appAtoms.ts create mode 100644 frontend/src/test/render.tsx diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index c50de55..441d272 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -104,17 +104,22 @@ services under one host — would let us skip the nginx `/api` proxy. - [x] `QueryClientProvider` + `ThemeProvider` wired in `main.tsx` - [x] Tests: `api.test.ts`, `useApiKeys.test.tsx`; gate green (11 tests) -### Phase 4 — Components (feature parity) -Each component gets its own PascalCase dir + co-located test + `index.ts` barrel. -- [ ] `Topbar` (logo, account dropdown: name/email, theme submenu light/dark/system, Sign out → `/logout`) -- [ ] `KeysCard` (table + loading/empty/error states + Create button) -- [ ] `KeyRowActions` (kebab → Revoke) -- [ ] `CreateKeyDialog` (model select + description + validation) -- [ ] `KeyCreatedDialog` (client ID, one-time key, copy, download `.txt`, warning) -- [ ] `RevokeKeyDialog` (destructive confirm) -- [ ] `ErrorBanner` / toast -- [ ] shadcn primitives added: button, dialog, table, select, input, label, - dropdown-menu, alert, card, avatar, sonner +### Phase 4 — Components (feature parity) ✅ +Each component gets its own PascalCase dir + `index.ts` barrel. +- [x] `Topbar` (logo, account dropdown: name/email, theme radio light/dark/system, Sign out → `/logout`) +- [x] `KeysCard` (table + loading/empty/error states + Create button) +- [x] `KeyRowActions` (kebab → Revoke, destructive) +- [x] `CreateKeyDialog` (model select + description + validation) +- [x] `KeyCreatedDialog` (client ID, one-time key, copy w/ feedback, download `.txt`, warning) +- [x] `RevokeKeyDialog` (destructive confirm) +- [x] `ErrorBanner` (dismissible, driven by `errorAtom`) +- [x] shadcn primitives in `components/ui/`: button, card, table, dropdown-menu, avatar, + input, badge (copied from nebari-landing) + dialog, select, label, alert (shadcn CLI) +- [x] Dialogs driven by `dialogAtom`; `lib/format.ts` (date + initials); `App.tsx` composes the page +- [x] Tests: `App.test.tsx`, `KeysCard.test.tsx`, `src/test/render.tsx` provider helper; gate green (14 tests) + +> Not yet done: visual verification against a **live backend** + Figma comparison. Deferred to +> Phase 6 (local dev brings up the API) / Phase 7 (polish). The build, type-check, and tests pass. ### Phase 5 — Serve separately (Docker + Helm + CI) - [ ] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` + diff --git a/frontend/index.html b/frontend/index.html index 8b7096a..62872cd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Local AI Model API Key Manager + LLM API Key Manager
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f60bb9d..7412a6b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,11 +1,36 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "@/test/render"; import App from "./App"; +function jsonResponse(body: unknown) { + return { ok: true, status: 200, json: async () => body, text: async () => "" } as Response; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("App", () => { - it("renders the app shell", () => { - render(); - expect(screen.getByText(/Local AI Model API Key Manager/i)).toBeInTheDocument(); + it("renders the page shell", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: string) => { + if (input.startsWith("/api/me")) { + return Promise.resolve( + jsonResponse({ username: "jane", name: "Jane Doe", email: "jane@x.io", groups: [] }), + ); + } + if (input.startsWith("/api/models")) { + return Promise.resolve(jsonResponse({ models: [] })); + } + return Promise.resolve(jsonResponse({ keys: [] })); + }), + ); + + const { findByRole, getByText } = renderWithProviders(); + expect(getByText("LLM API Key Manager", { selector: "h1" })).toBeInTheDocument(); + expect(await findByRole("button", { name: /create new key/i })).toBeInTheDocument(); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 32c3328..afed3b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,32 @@ +import { CreateKeyDialog } from "@/components/CreateKeyDialog"; +import { ErrorBanner } from "@/components/ErrorBanner"; +import { KeyCreatedDialog } from "@/components/KeyCreatedDialog"; +import { KeysCard } from "@/components/KeysCard"; +import { RevokeKeyDialog } from "@/components/RevokeKeyDialog"; +import { Topbar } from "@/components/Topbar"; + export default function App() { return ( -
-

- Local AI Model API Key Manager — scaffolding in progress. -

-
+
+ + +
+
+

LLM API Key Manager

+

+ View and manage your API keys. Do not share your API keys with others or expose them in + the browser or shared repositories. +

+
+ + + + +
+ + + + +
); } diff --git a/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx b/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx new file mode 100644 index 0000000..57ea12c --- /dev/null +++ b/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx @@ -0,0 +1,112 @@ +import { useAtom } from "jotai"; +import { type FormEvent, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useCreateKey } from "@/hooks/useApiKeys"; +import { useModels } from "@/hooks/useModels"; +import { dialogAtom } from "@/store/dialogAtoms"; + +export function CreateKeyDialog() { + const [dialog, setDialog] = useAtom(dialogAtom); + const { data: models } = useModels(); + const createKey = useCreateKey(); + + const [modelName, setModelName] = useState(""); + const [description, setDescription] = useState(""); + const [fieldError, setFieldError] = useState(null); + + const open = dialog.type === "create"; + + function close() { + setDialog({ type: "none" }); + setModelName(""); + setDescription(""); + setFieldError(null); + } + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setFieldError(null); + + if (!modelName) { + setFieldError("Please select a model."); + return; + } + + try { + const result = await createKey.mutateAsync({ modelName, description: description.trim() }); + setDialog({ type: "created", clientId: result.clientId, apiKey: result.apiKey }); + setModelName(""); + setDescription(""); + } catch (err) { + setFieldError( + `Failed to create key: ${err instanceof Error ? err.message : "unknown error"}`, + ); + } + } + + return ( + !next && close()}> + + + Create API Key + +
+
+ + +
+ +
+ + setDescription(e.target.value)} + /> +
+ + {fieldError ?

{fieldError}

: null} + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/CreateKeyDialog/index.ts b/frontend/src/components/CreateKeyDialog/index.ts new file mode 100644 index 0000000..4c1910b --- /dev/null +++ b/frontend/src/components/CreateKeyDialog/index.ts @@ -0,0 +1 @@ +export { CreateKeyDialog } from "./CreateKeyDialog"; diff --git a/frontend/src/components/ErrorBanner/ErrorBanner.tsx b/frontend/src/components/ErrorBanner/ErrorBanner.tsx new file mode 100644 index 0000000..01ab570 --- /dev/null +++ b/frontend/src/components/ErrorBanner/ErrorBanner.tsx @@ -0,0 +1,31 @@ +import { useAtom } from "jotai"; +import { AlertTriangle, X } from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { errorAtom } from "@/store/appAtoms"; + +/** Dismissible page-level error banner driven by `errorAtom`. */ +export function ErrorBanner() { + const [error, setError] = useAtom(errorAtom); + + if (!error) { + return null; + } + + return ( + + + {error} + + + ); +} diff --git a/frontend/src/components/ErrorBanner/index.ts b/frontend/src/components/ErrorBanner/index.ts new file mode 100644 index 0000000..d274edd --- /dev/null +++ b/frontend/src/components/ErrorBanner/index.ts @@ -0,0 +1 @@ +export { ErrorBanner } from "./ErrorBanner"; diff --git a/frontend/src/components/KeyCreatedDialog/KeyCreatedDialog.tsx b/frontend/src/components/KeyCreatedDialog/KeyCreatedDialog.tsx new file mode 100644 index 0000000..6b978a0 --- /dev/null +++ b/frontend/src/components/KeyCreatedDialog/KeyCreatedDialog.tsx @@ -0,0 +1,107 @@ +import { useAtom } from "jotai"; +import { AlertTriangle, Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { dialogAtom } from "@/store/dialogAtoms"; + +export function KeyCreatedDialog() { + const [dialog, setDialog] = useAtom(dialogAtom); + const [copied, setCopied] = useState(false); + + const open = dialog.type === "created"; + const clientId = dialog.type === "created" ? dialog.clientId : ""; + const apiKey = dialog.type === "created" ? dialog.apiKey : ""; + + function close() { + setDialog({ type: "none" }); + setCopied(false); + } + + async function copyKey() { + try { + await navigator.clipboard.writeText(apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard can be unavailable (insecure context); the field stays + // selectable so the user can copy manually. + } + } + + function download() { + const contents = `Client ID: ${clientId}\nAPI Key: ${apiKey}\n`; + const blob = new Blob([contents], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${clientId || "api-key"}.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + return ( + !next && close()}> + + + API Key Created + + +
+
+ + +
+ +
+ +
+ e.currentTarget.select()} + /> + +
+
+ + + + Copy this key now. It will not be shown again. + + +

+ For security reasons, this key is only displayed once and cannot be retrieved later. If + you lose it, you'll need to create a new one. +

+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/KeyCreatedDialog/index.ts b/frontend/src/components/KeyCreatedDialog/index.ts new file mode 100644 index 0000000..8fabbb3 --- /dev/null +++ b/frontend/src/components/KeyCreatedDialog/index.ts @@ -0,0 +1 @@ +export { KeyCreatedDialog } from "./KeyCreatedDialog"; diff --git a/frontend/src/components/KeyRowActions/KeyRowActions.tsx b/frontend/src/components/KeyRowActions/KeyRowActions.tsx new file mode 100644 index 0000000..4be0a31 --- /dev/null +++ b/frontend/src/components/KeyRowActions/KeyRowActions.tsx @@ -0,0 +1,37 @@ +import { useSetAtom } from "jotai"; +import { MoreVertical, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { ApiKey } from "@/lib/types"; +import { dialogAtom } from "@/store/dialogAtoms"; + +/** Per-row kebab menu exposing the destructive Revoke action. */ +export function KeyRowActions({ apiKey }: { apiKey: ApiKey }) { + const setDialog = useSetAtom(dialogAtom); + + return ( + + + + + + Danger + setDialog({ type: "revoke", key: apiKey })} + > + Revoke + + + + ); +} diff --git a/frontend/src/components/KeyRowActions/index.ts b/frontend/src/components/KeyRowActions/index.ts new file mode 100644 index 0000000..738f03f --- /dev/null +++ b/frontend/src/components/KeyRowActions/index.ts @@ -0,0 +1 @@ +export { KeyRowActions } from "./KeyRowActions"; diff --git a/frontend/src/components/KeysCard/KeysCard.test.tsx b/frontend/src/components/KeysCard/KeysCard.test.tsx new file mode 100644 index 0000000..ef6bd66 --- /dev/null +++ b/frontend/src/components/KeysCard/KeysCard.test.tsx @@ -0,0 +1,55 @@ +import { screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "@/test/render"; + +import { KeysCard } from "./KeysCard"; + +function stubKeys(body: unknown, ok = true, status = 200) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok, + status, + json: async () => body, + text: async () => "boom", + } as unknown as Response), + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("KeysCard", () => { + it("shows the empty state when there are no keys", async () => { + stubKeys({ keys: [] }); + renderWithProviders(); + expect(await screen.findByText("No API keys yet.")).toBeInTheDocument(); + }); + + it("renders a row per key", async () => { + stubKeys({ + keys: [ + { + clientId: "user-jane-1", + creator: "jane", + description: "Research notebook", + createdAt: "2026-01-02T00:00:00Z", + modelName: "llama-3", + namespace: "ns", + }, + ], + }); + renderWithProviders(); + expect(await screen.findByText("Research notebook")).toBeInTheDocument(); + expect(screen.getByText("user-jane-1")).toBeInTheDocument(); + expect(screen.getByText("llama-3")).toBeInTheDocument(); + }); + + it("shows an error state when the request fails", async () => { + stubKeys({}, false, 500); + renderWithProviders(); + await waitFor(() => expect(screen.getByText(/Failed to load keys/i)).toBeInTheDocument()); + }); +}); diff --git a/frontend/src/components/KeysCard/KeysCard.tsx b/frontend/src/components/KeysCard/KeysCard.tsx new file mode 100644 index 0000000..a331a74 --- /dev/null +++ b/frontend/src/components/KeysCard/KeysCard.tsx @@ -0,0 +1,74 @@ +import { useSetAtom } from "jotai"; +import { Plus } from "lucide-react"; + +import { KeyRowActions } from "@/components/KeyRowActions"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useApiKeys } from "@/hooks/useApiKeys"; +import { formatDate } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import { dialogAtom } from "@/store/dialogAtoms"; + +export function KeysCard({ className }: { className?: string }) { + const setDialog = useSetAtom(dialogAtom); + const { data: keys, isLoading, isError, error } = useApiKeys(); + + return ( + + + My API Keys + + + + + + {isLoading ? ( +

Loading keys…

+ ) : isError ? ( +

+ Failed to load keys: {error instanceof Error ? error.message : "unknown error"} +

+ ) : !keys || keys.length === 0 ? ( +

No API keys yet.

+ ) : ( + + + + Name / Description + Client ID + Model + Created + Action + + + + {keys.map((key) => ( + + {key.description || "—"} + {key.clientId} + {key.modelName} + + {formatDate(key.createdAt)} + + + + + + ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/KeysCard/index.ts b/frontend/src/components/KeysCard/index.ts new file mode 100644 index 0000000..a17454a --- /dev/null +++ b/frontend/src/components/KeysCard/index.ts @@ -0,0 +1 @@ +export { KeysCard } from "./KeysCard"; diff --git a/frontend/src/components/RevokeKeyDialog/RevokeKeyDialog.tsx b/frontend/src/components/RevokeKeyDialog/RevokeKeyDialog.tsx new file mode 100644 index 0000000..1f538f0 --- /dev/null +++ b/frontend/src/components/RevokeKeyDialog/RevokeKeyDialog.tsx @@ -0,0 +1,69 @@ +import { useAtom, useSetAtom } from "jotai"; +import { Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useRevokeKey } from "@/hooks/useApiKeys"; +import { errorAtom } from "@/store/appAtoms"; +import { dialogAtom } from "@/store/dialogAtoms"; + +export function RevokeKeyDialog() { + const [dialog, setDialog] = useAtom(dialogAtom); + const setError = useSetAtom(errorAtom); + const revokeKey = useRevokeKey(); + + const open = dialog.type === "revoke"; + const key = dialog.type === "revoke" ? dialog.key : null; + const label = key?.description || key?.clientId || ""; + + function close() { + setDialog({ type: "none" }); + } + + async function confirm() { + if (!key) { + return; + } + try { + await revokeKey.mutateAsync(key); + close(); + } catch (err) { + close(); + setError(`Failed to revoke key: ${err instanceof Error ? err.message : "unknown error"}`); + } + } + + return ( + !next && close()}> + + + Revoke API Key? + + This permanently disables "{label}". Any application using this key will immediately + lose access. This can't be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/RevokeKeyDialog/index.ts b/frontend/src/components/RevokeKeyDialog/index.ts new file mode 100644 index 0000000..a38d3b5 --- /dev/null +++ b/frontend/src/components/RevokeKeyDialog/index.ts @@ -0,0 +1 @@ +export { RevokeKeyDialog } from "./RevokeKeyDialog"; diff --git a/frontend/src/components/Topbar/Topbar.tsx b/frontend/src/components/Topbar/Topbar.tsx new file mode 100644 index 0000000..3a490e1 --- /dev/null +++ b/frontend/src/components/Topbar/Topbar.tsx @@ -0,0 +1,117 @@ +import { ChevronDown, Monitor, Moon, Sun } from "lucide-react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import type { ReactNode } from "react"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { isThemeMode, type ThemeMode } from "@/hooks/useThemePreference"; +import { userInitials } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import { useTheme } from "@/providers/ThemeProvider"; + +export function Topbar() { + const { data: user } = useCurrentUser(); + const { themeMode, setThemeMode } = useTheme(); + + const displayName = user?.name || user?.username || user?.email || "Account"; + + return ( +
+ + Nebari + + + + + + + + +
+

+ {user?.name || user?.username || "Signed in"} +

+ {user?.email ?

{user.email}

: null} +
+ +
+ { + if (isThemeMode(value)) setThemeMode(value); + }} + className="flex items-center gap-1 rounded-lg bg-muted p-1" + > + + + + + + + + + + +
+ + + + + Sign out + +
+
+
+ ); +} + +function ThemeOption({ + value, + label, + text, + children, +}: { + value: ThemeMode; + label: string; + text: string; + children: ReactNode; +}) { + return ( + event.preventDefault()} + className={cn( + "flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50", + "text-muted-foreground hover:text-foreground", + "data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm", + )} + > + {children} + {text} + + ); +} diff --git a/frontend/src/components/Topbar/index.ts b/frontend/src/components/Topbar/index.ts new file mode 100644 index 0000000..d6f8f82 --- /dev/null +++ b/frontend/src/components/Topbar/index.ts @@ -0,0 +1 @@ +export { Topbar } from "./Topbar"; diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..ff40e63 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,73 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertAction, AlertDescription, AlertTitle }; diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..0bdbd4e --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,94 @@ +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className, + )} + {...props} + /> + ); +} + +export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..17e7844 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,45 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..417f33f --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,67 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", + lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-9", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot.Root : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..c5d6e59 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..85df34f --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,139 @@ +import { XIcon } from "lucide-react"; +import { Dialog as DialogPrimitive } from "radix-ui"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ad0092c --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,247 @@ +import { CheckIcon, ChevronRightIcon } from "lucide-react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..e72e556 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..ce80d7e --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Label as LabelPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..19be392 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,182 @@ +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { Select as SelectPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..889ca32 --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,84 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ; +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ); +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + - - - - - - - - - `; - - const tbody = document.createElement('tbody'); - for (const k of keys) { - const clientId = k.clientId || ''; - const modelName = k.modelName || ''; - const ns = k.namespace || ''; - const desc = k.description || ''; - const created = k.createdAt ? formatDate(k.createdAt) : '—'; - - const tr = document.createElement('tr'); - tr.innerHTML = ` - - - - - - `; - tr.cells[4].appendChild(buildActionMenu({ ns, modelName, clientId, desc })); - tbody.appendChild(tr); - } - table.appendChild(tbody); - - container.innerHTML = ''; - container.appendChild(table); -} - -function formatDate(iso) { - const d = new Date(iso); - if (isNaN(d)) return '—'; - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); -} - -// --------------------------------------------------------------------------- -// Row action menu (kebab dropdown) -// --------------------------------------------------------------------------- -function closeAllMenus() { - document.querySelectorAll('.menu').forEach((m) => m.remove()); -} - -function buildActionMenu(key) { - const wrap = document.createElement('div'); - wrap.className = 'menu-wrap'; - - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'btn-icon'; - btn.setAttribute('aria-label', 'Key actions'); - btn.innerHTML = ` - - `; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const isOpen = wrap.querySelector('.menu'); - closeAllMenus(); - if (!isOpen) wrap.appendChild(buildMenu(key)); - }); - - wrap.appendChild(btn); - return wrap; -} - -function buildMenu(key) { - const menu = document.createElement('div'); - menu.className = 'menu'; - menu.innerHTML = ''; - - const revoke = document.createElement('button'); - revoke.type = 'button'; - revoke.className = 'menu-item destructive'; - revoke.innerHTML = ` - - Revoke - `; - revoke.addEventListener('click', () => { - closeAllMenus(); - openRevokeDialog(key); - }); - - menu.appendChild(revoke); - return menu; -} - -// Close any open menu when clicking elsewhere. -document.addEventListener('click', closeAllMenus); - -// --------------------------------------------------------------------------- -// Create key flow -// --------------------------------------------------------------------------- -$('create-key-btn').addEventListener('click', () => { - clearFieldError('create-error'); - $('model-select').value = ''; - $('description-input').value = ''; - openDialog('create-dialog'); -}); - -$('create-key-form').addEventListener('submit', async (e) => { - e.preventDefault(); - clearFieldError('create-error'); - clearError(); - - const modelName = $('model-select').value; - const description = $('description-input').value.trim(); - - if (!modelName) { - showFieldError('create-error', 'Please select a model.'); - return; - } - - const btn = $('create-submit-btn'); - btn.disabled = true; - btn.textContent = 'Creating…'; - - try { - const result = await apiFetch('POST', '/api/keys', { modelName, description }); - closeDialog('create-dialog'); - showCreatedDialog(result.apiKey, result.clientId); - loadKeys(); - } catch (err) { - showFieldError('create-error', 'Failed to create key: ' + err.message); - } finally { - btn.disabled = false; - btn.textContent = 'Create'; - } -}); - -// --------------------------------------------------------------------------- -// API Key Created dialog -// --------------------------------------------------------------------------- -function showCreatedDialog(apiKey, clientId) { - $('created-client-id').value = clientId; - $('created-api-key').value = apiKey; - $('copy-btn').textContent = 'Copy'; - openDialog('created-dialog'); -} - -$('copy-btn').addEventListener('click', async () => { - const key = $('created-api-key').value; - try { - await navigator.clipboard.writeText(key); - $('copy-btn').textContent = 'Copied!'; - setTimeout(() => { $('copy-btn').textContent = 'Copy'; }, 2000); - } catch { - $('created-api-key').select(); - } -}); - -$('download-btn').addEventListener('click', () => { - const clientId = $('created-client-id').value; - const apiKey = $('created-api-key').value; - const contents = `Client ID: ${clientId}\nAPI Key: ${apiKey}\n`; - const blob = new Blob([contents], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${clientId || 'api-key'}.txt`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -}); - -$('created-done-btn').addEventListener('click', () => { - closeDialog('created-dialog'); - $('created-client-id').value = ''; - $('created-api-key').value = ''; -}); - -// --------------------------------------------------------------------------- -// Revoke flow -// --------------------------------------------------------------------------- -let pendingRevoke = null; - -function openRevokeDialog(key) { - pendingRevoke = key; - const name = key.desc || key.clientId; - $('revoke-message').textContent = - `This permanently disables "${name}". Any application using this key will immediately lose access. This can't be undone.`; - openDialog('revoke-dialog'); -} - -$('revoke-confirm-btn').addEventListener('click', async () => { - if (!pendingRevoke) return; - const { ns, modelName, clientId } = pendingRevoke; - const btn = $('revoke-confirm-btn'); - btn.disabled = true; - try { - await apiFetch('DELETE', `/api/keys/${encodeURIComponent(ns)}/${encodeURIComponent(modelName)}/${encodeURIComponent(clientId)}`); - closeDialog('revoke-dialog'); - pendingRevoke = null; - loadKeys(); - } catch (err) { - closeDialog('revoke-dialog'); - showError('Failed to revoke key: ' + err.message); - } finally { - btn.disabled = false; - } -}); - -// --------------------------------------------------------------------------- -// Account menu -// --------------------------------------------------------------------------- -function userInitials(name, email) { - if (name) { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - } - if (email) return email.slice(0, 2).toUpperCase(); - return 'U'; -} - -async function loadMe() { - try { - const me = await apiFetch('GET', '/api/me'); - const displayName = me.name || me.username || me.email || 'Account'; - $('account-avatar').textContent = userInitials(me.name || me.username, me.email); - $('account-name').textContent = displayName; - $('account-menu-name').textContent = me.name || me.username || 'Signed in'; - const emailEl = $('account-menu-email'); - if (me.email) { - emailEl.textContent = me.email; - emailEl.classList.remove('hidden'); - } else { - emailEl.classList.add('hidden'); - } - } catch (err) { - // Non-fatal: leave the default "Account" label in place. - console.error('failed to load account info:', err); - } -} - -function closeAccountMenu() { - $('account-menu').classList.add('hidden'); - $('account-btn').setAttribute('aria-expanded', 'false'); -} - -$('account-btn').addEventListener('click', (e) => { - e.stopPropagation(); - closeAllMenus(); - const menu = $('account-menu'); - const isOpen = !menu.classList.contains('hidden'); - if (isOpen) { - closeAccountMenu(); - } else { - menu.classList.remove('hidden'); - $('account-btn').setAttribute('aria-expanded', 'true'); - } -}); - -$('signout-btn').addEventListener('click', () => { - window.location.href = '/logout'; -}); - -// Close the account menu when clicking elsewhere. -document.addEventListener('click', closeAccountMenu); - -// --------------------------------------------------------------------------- -// Initialise -// --------------------------------------------------------------------------- -loadMe(); -loadModels(); -loadKeys(); diff --git a/key-manager/internal/ui/static/favicon.svg b/key-manager/internal/ui/static/favicon.svg deleted file mode 100644 index 9e55146..0000000 --- a/key-manager/internal/ui/static/favicon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/key-manager/internal/ui/static/index.html b/key-manager/internal/ui/static/index.html deleted file mode 100644 index e68fe89..0000000 --- a/key-manager/internal/ui/static/index.html +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - Local AI Model API Key Manager - - - - - - - -
-
-

Local AI Model API Key Manager

-

View and manage your API keys. Do not share your API keys with others or expose them in the browser or shared repositories.

-
- - - - -
-
-

My API Keys

- -
-
-
Loading keys…
- -
-
- - - - - - - - - - - - - diff --git a/key-manager/internal/ui/static/nebari-logo.svg b/key-manager/internal/ui/static/nebari-logo.svg deleted file mode 100644 index 0ab6aab..0000000 --- a/key-manager/internal/ui/static/nebari-logo.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/key-manager/internal/ui/static/style.css b/key-manager/internal/ui/static/style.css deleted file mode 100644 index 2cc0040..0000000 --- a/key-manager/internal/ui/static/style.css +++ /dev/null @@ -1,530 +0,0 @@ -/* --------------------------------------------------------------------------- - Design tokens (from Figma: LLM API Key Manager) - --------------------------------------------------------------------------- */ -:root { - /* Color */ - --color-primary: #022791; - --color-primary-hover: #021f74; - --color-fg-default: #09090b; - --color-fg-muted: #71717a; - --color-fg-muted-strong: #3f3f46; - --color-fg-on-brand: #ffffff; - --color-bg-default: #fafafa; - --color-bg-card: #ffffff; - --color-bg-muted: #f4f4f5; - --color-bg-popover: #ffffff; - --color-border-default: #d4d4d8; - --color-border-input: #a1a1aa; - --color-fg-destructive: #dc2626; - --color-bg-destructive: #fef2f2; - --color-fg-warning: #a16207; - --color-bg-warning: #fefce8; - --color-fg-success: #15803d; - --color-bg-success: #f0fdf4; - - /* Radius */ - --radius-md: 6px; - --radius-lg: 8px; - --radius-full: 9999px; - - /* Spacing */ - --space-2: 2px; - --space-4: 4px; - --space-6: 6px; - --space-8: 8px; - --space-10: 10px; - --space-12: 12px; - --space-16: 16px; - --space-24: 24px; - --space-32: 32px; - --space-40: 40px; - --space-48: 48px; - - /* Shadow */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); - --shadow-popover: 0 4px 12px rgba(0, 0, 0, 0.1); - - /* Type */ - --font-sans: "Geist", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: var(--font-sans); - font-size: 14px; - line-height: 20px; - color: var(--color-fg-default); - background: var(--color-bg-default); -} - -.icon { flex: none; } - -.hidden { display: none !important; } - -/* --------------------------------------------------------------------------- - Top bar - --------------------------------------------------------------------------- */ -.topbar { - height: 60px; - background: var(--color-bg-card); - border-bottom: 1px solid var(--color-border-default); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 var(--space-40); -} - -.topbar-brand { - display: flex; - align-items: center; - gap: var(--space-8); -} - -.logo { - display: block; - height: 32px; - width: auto; -} - -.topbar-account { - position: relative; -} - -.account-trigger { - display: flex; - align-items: center; - gap: var(--space-12); - padding: var(--space-4) var(--space-8); - border: none; - background: transparent; - border-radius: var(--radius-md); - color: var(--color-fg-muted-strong); - font-family: inherit; - cursor: pointer; -} -.account-trigger:hover { background: var(--color-bg-muted); } -.account-trigger:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} - -.avatar { - width: 32px; - height: 32px; - flex: none; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-full); - background: var(--color-primary); - color: var(--color-fg-on-brand); - font-size: 13px; - font-weight: 600; - line-height: 1; - text-transform: uppercase; -} - -.account-name { - font-size: 14px; - font-weight: 500; - color: var(--color-fg-default); -} - -.account-chevron { - color: var(--color-fg-muted); - transition: transform 0.15s ease; -} -.account-trigger[aria-expanded="true"] .account-chevron { - transform: rotate(180deg); -} - -/* Account dropdown */ -.account-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; - width: 224px; - background: var(--color-bg-popover); - border: 1px solid var(--color-border-default); - border-radius: var(--radius-md); - box-shadow: var(--shadow-popover); - padding: var(--space-4); - z-index: 50; -} - -.account-menu-head { - padding: var(--space-8) var(--space-8) var(--space-10); - margin-bottom: var(--space-4); - border-bottom: 1px solid var(--color-border-default); -} - -.account-menu-name { - font-size: 14px; - font-weight: 500; - color: var(--color-fg-default); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.account-menu-email { - margin-top: var(--space-2); - font-size: 12px; - color: var(--color-fg-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* --------------------------------------------------------------------------- - Page layout - --------------------------------------------------------------------------- */ -.page { - width: 100%; - padding: var(--space-24) var(--space-48); - display: flex; - flex-direction: column; - gap: var(--space-24); -} - -.page-title { - font-size: 30px; - line-height: 36px; - font-weight: 700; -} - -.page-subtitle { - margin-top: var(--space-8); - font-size: 14px; - color: var(--color-fg-muted); -} - -/* --------------------------------------------------------------------------- - Card - --------------------------------------------------------------------------- */ -.card { - background: var(--color-bg-card); - border: 1px solid var(--color-border-default); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - padding: var(--space-16); -} - -.card-head { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 36px; -} - -.card-title { - font-size: 18px; - line-height: 24px; - font-weight: 600; -} - -.card-divider { - height: 1px; - background: var(--color-border-default); - margin: var(--space-16) 0; -} - -.state-text { - color: var(--color-fg-muted); - padding: var(--space-4) 0; -} - -/* --------------------------------------------------------------------------- - Buttons - --------------------------------------------------------------------------- */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-8); - height: 36px; - padding: 0 var(--space-12); - border: 1px solid transparent; - border-radius: var(--radius-md); - font-family: inherit; - font-size: 14px; - font-weight: 500; - line-height: 20px; - cursor: pointer; - white-space: nowrap; -} - -.btn:disabled { opacity: 0.6; cursor: not-allowed; } - -.btn-primary { - background: var(--color-primary); - color: var(--color-fg-on-brand); -} -.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); } - -.btn-secondary { - background: var(--color-bg-card); - color: var(--color-fg-default); - border-color: var(--color-border-default); -} -.btn-secondary:hover:not(:disabled) { background: var(--color-bg-muted); } - -.btn-destructive { - background: var(--color-bg-destructive); - color: var(--color-fg-destructive); - border-color: var(--color-fg-destructive); -} -.btn-destructive:hover:not(:disabled) { background: #fde6e6; } - -.btn-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--color-fg-muted); - cursor: pointer; -} -.btn-icon:hover { background: var(--color-bg-muted); color: var(--color-fg-default); } - -/* --------------------------------------------------------------------------- - Table - --------------------------------------------------------------------------- */ -.keys-table { - width: 100%; - border-collapse: collapse; -} - -.keys-table th { - text-align: left; - font-size: 12px; - font-weight: 500; - color: var(--color-fg-muted); - padding: var(--space-10) var(--space-12); - border-bottom: 1px solid var(--color-border-default); -} - -.keys-table td { - padding: var(--space-12); - font-size: 14px; - border-bottom: 1px solid var(--color-bg-muted); - vertical-align: middle; -} - -.keys-table tr:last-child td { border-bottom: none; } - -.cell-name { font-weight: 500; } - -.cell-mono { - font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - font-size: 13px; - color: var(--color-fg-muted-strong); -} - -.cell-muted { color: var(--color-fg-muted); } - -.cell-action { - width: 70px; - text-align: right; - position: relative; -} - -.empty-state { - color: var(--color-fg-muted); - padding: var(--space-4) 0; -} - -/* --------------------------------------------------------------------------- - Row action menu (kebab dropdown) - --------------------------------------------------------------------------- */ -.menu-wrap { position: relative; display: inline-block; } - -.menu { - position: absolute; - top: calc(100% + 4px); - right: 0; - min-width: 140px; - background: var(--color-bg-popover); - border: 1px solid var(--color-border-default); - border-radius: var(--radius-md); - box-shadow: var(--shadow-popover); - padding: var(--space-4); - z-index: 50; -} - -.menu-label { - font-size: 11px; - font-weight: 500; - letter-spacing: 0.8px; - text-transform: uppercase; - color: var(--color-fg-muted); - padding: var(--space-6) var(--space-8); -} - -.menu-separator { - height: 1px; - background: var(--color-border-default); - margin: var(--space-4) 0; -} - -.menu-item { - display: flex; - align-items: center; - gap: var(--space-8); - width: 100%; - padding: var(--space-6) var(--space-8); - border: none; - background: transparent; - border-radius: var(--radius-md); - font-family: inherit; - font-size: 14px; - text-align: left; - cursor: pointer; - color: var(--color-fg-default); -} -.menu-item:hover { background: var(--color-bg-muted); } -.menu-item.destructive { color: var(--color-fg-destructive); } -.menu-item.destructive:hover { background: var(--color-bg-destructive); } - -/* --------------------------------------------------------------------------- - Dialogs - --------------------------------------------------------------------------- */ -.dialog-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; -} - -.dialog { - background: var(--color-bg-card); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-popover); - max-width: calc(100vw - 32px); -} - -.dialog-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - padding: var(--space-16); -} - -.dialog-title { - font-size: 18px; - line-height: 24px; - font-weight: 600; -} - -.dialog-body { - display: flex; - flex-direction: column; - gap: var(--space-12); - padding: 0 var(--space-16) var(--space-16); -} - -.dialog-foot { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: var(--space-4); -} - -.dialog-text { - font-size: 14px; - color: var(--color-fg-muted-strong); -} - -/* --------------------------------------------------------------------------- - Form fields - --------------------------------------------------------------------------- */ -.field { - display: flex; - flex-direction: column; - gap: var(--space-6); -} - -.field label { - font-size: 14px; - font-weight: 500; -} - -.field select, -.field input[type="text"] { - width: 100%; - height: 40px; - padding: 0 var(--space-12); - border: 1px solid var(--color-border-input); - border-radius: var(--radius-md); - font-family: inherit; - font-size: 14px; - color: var(--color-fg-default); - background: var(--color-bg-card); -} - -.field select:focus, -.field input[type="text"]:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 2px rgba(2, 39, 145, 0.2); -} - -.readonly-input { - background: var(--color-bg-muted) !important; - color: var(--color-fg-muted-strong); - font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - font-size: 13px; -} - -.key-row { - display: flex; - gap: var(--space-8); -} -.key-row .readonly-input { flex: 1; } -.key-row .btn { height: 40px; } - -.helper-text { - font-size: 12px; - line-height: 16px; - color: var(--color-fg-muted); -} - -.field-error { - color: var(--color-fg-destructive); - font-size: 12px; -} - -/* --------------------------------------------------------------------------- - Alerts - --------------------------------------------------------------------------- */ -.alert { - display: flex; - align-items: center; - gap: var(--space-8); - padding: var(--space-8) var(--space-12); - border-radius: var(--radius-md); - font-size: 14px; -} - -.alert-warning { - background: var(--color-bg-warning); - color: var(--color-fg-warning); - border: 1px solid var(--color-fg-warning); -} - -.alert-destructive { - background: var(--color-bg-destructive); - color: var(--color-fg-destructive); - border: 1px solid var(--color-fg-destructive); -} From 63809bfdc906c3c6f5a5c4d630cf94ac65ebef3a Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 8 Jul 2026 08:30:14 -0400 Subject: [PATCH 11/15] Use design system base colors and table. --- frontend/src/App.tsx | 2 +- frontend/src/components/KeysCard/KeysCard.tsx | 90 +++++++------- frontend/src/components/Topbar/Topbar.tsx | 2 +- frontend/src/components/ui/table.tsx | 114 +++++++++++++++--- frontend/src/index.css | 11 +- 5 files changed, 154 insertions(+), 65 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afed3b9..26bb07f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,7 @@ import { Topbar } from "@/components/Topbar"; export default function App() { return ( -
+
diff --git a/frontend/src/components/KeysCard/KeysCard.tsx b/frontend/src/components/KeysCard/KeysCard.tsx index 130700f..a730145 100644 --- a/frontend/src/components/KeysCard/KeysCard.tsx +++ b/frontend/src/components/KeysCard/KeysCard.tsx @@ -3,7 +3,6 @@ import { Plus } from "lucide-react"; import { KeyRowActions } from "@/components/KeyRowActions"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, @@ -22,53 +21,48 @@ export function KeysCard({ className }: { className?: string }) { const { data: keys, isLoading, isError, error } = useApiKeys(); return ( - - - My API Keys - - - - - - {isLoading ? ( -

Loading keys…

- ) : isError ? ( -

- Failed to load keys: {error instanceof Error ? error.message : "unknown error"} -

- ) : !keys || keys.length === 0 ? ( -

No API keys yet.

- ) : ( -
+ ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..77abd9d --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,25 @@ +/** Format an ISO timestamp as a short, locale-aware date, or "—" if invalid. */ +export function formatDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + return "—"; + } + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +/** Up to two uppercase initials derived from a name, falling back to email. */ +export function userInitials(name?: string, email?: string): string { + if (name) { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + } + if (email) { + return email.slice(0, 2).toUpperCase(); + } + return "U"; +} diff --git a/frontend/src/store/appAtoms.ts b/frontend/src/store/appAtoms.ts new file mode 100644 index 0000000..4237d41 --- /dev/null +++ b/frontend/src/store/appAtoms.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; + +/** Page-level error banner message, or null when there is nothing to show. */ +export const errorAtom = atom(null); diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx new file mode 100644 index 0000000..a18b470 --- /dev/null +++ b/frontend/src/test/render.tsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type RenderOptions, render } from "@testing-library/react"; +import { Provider as JotaiProvider } from "jotai"; +import type { ReactElement, ReactNode } from "react"; + +import { ThemeProvider } from "@/providers/ThemeProvider"; + +/** + * Render a component wrapped in the app's providers (fresh QueryClient + Jotai + * store per call, plus ThemeProvider) so tests are isolated from one another. + */ +export function renderWithProviders(ui: ReactElement, options?: Omit) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } + + return render(ui, { wrapper: Wrapper, ...options }); +} From 6c6cbfa0e0b0ac613d51e89d957b370756f720d6 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 24 Jun 2026 14:50:15 -0400 Subject: [PATCH 05/15] Update plan for ui auth. --- frontend-rewrite-plan.md | 65 ++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index 441d272..410f9e1 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -20,6 +20,15 @@ Tracking issue: **#92 — Rebuild the LLM Serving Pack UI with React + TypeScrip - **Stack:** React 19 + TS + Vite 7, Tailwind v4 (`@tailwindcss/vite`), shadcn/ui + radix-ui, lucide-react, TanStack Query v5, Jotai v2, Biome 2, Vitest 4 + Testing Library. npm, dev port 5173. +- **Auth: Model B — SPA-managed Keycloak** (matches nebari-landing). The SPA + owns login via `keycloak-js` (`onLoad: "login-required"` + PKCE) and attaches + `Authorization: Bearer ` on every `/api` call (refresh on 401, retry + once, else redirect to login). Keycloak `{ url, realm, clientId }` is loaded + at runtime from **`/config.json`** (Helm-rendered, mounted into nginx — no + rebuild to change). The gateway shifts from oauth2-proxy cookie injection to + an Envoy **JWT `SecurityPolicy`** that validates the bearer; the key-manager + middleware already accepts `Authorization: Bearer` and parses claims (Envoy + validates the signature upstream) — so the Go backend is unchanged. ## Reference template — nebari-landing (`~/repos/nebari-landing`) @@ -60,18 +69,22 @@ The UI is a single-page **API Key Manager** embedded in the Go binary --- -## Auth constraint that shapes deployment +## Auth + deployment shape (Model B) -The Keycloak cookie set by the NebariApp gateway is scoped to **one hostname**, so -the SPA (`/`) and the API (`/api`, `/logout`) must share a host. The `NebariApp` CRD -targets a single `service:port`. +Auth is **SPA-managed Keycloak with bearer tokens** (see Locked decisions). This +relaxes the same-origin cookie constraint — the token rides in the +`Authorization` header, not a host-scoped cookie — but we still serve the SPA and +API under one host for simplicity and so the JWT `SecurityPolicy` covers both. -**Primary approach:** the **nginx frontend container is the service the NebariApp -targets**, and nginx reverse-proxies `/api/*` and `/logout` to the key-manager -ClusterIP (`:8080`). The Go key-manager drops static serving and becomes API-only. +**Deployment approach:** the **nginx frontend container is the service the +NebariApp targets**; nginx serves the SPA + `/config.json` and reverse-proxies +`/api/*` to the key-manager ClusterIP (`:8080`). The Go key-manager drops static +serving and becomes API-only. The gateway enforces an Envoy **JWT +`SecurityPolicy`** (validates the bearer) instead of injecting an `IdToken` +cookie; key-manager parses the already-validated bearer. -**Fallback** (only if confirmed supported): gateway-level path routing to two -services under one host — would let us skip the nginx `/api` proxy. +Login/logout are driven by `keycloak-js` in the SPA (redirect to Keycloak), so +the old `/logout` proxy route is replaced by `keycloak.logout()`. --- @@ -121,22 +134,38 @@ Each component gets its own PascalCase dir + `index.ts` barrel. > Not yet done: visual verification against a **live backend** + Figma comparison. Deferred to > Phase 6 (local dev brings up the API) / Phase 7 (polish). The build, type-check, and tests pass. +### Phase 4b — Auth (Model B: SPA-managed Keycloak) +Mirror nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. +- [ ] Add `keycloak-js` dependency +- [ ] `src/app/config.ts` — load + cache `/config.json` (`keycloak: { url, realm, clientId }`) +- [ ] `src/auth/keycloak.ts` — `initKeycloak()` (`login-required` + PKCE), `getToken()` w/ refresh, + `SessionExpiredError`, `signOut()` → `keycloak.logout()` +- [ ] `src/auth/user.ts` — read identity from the ID token (can replace/augment `useCurrentUser`) +- [ ] `api.ts` — attach `Authorization: Bearer `; on 401 refresh + retry once +- [ ] `main.tsx` — `await loadAppConfig()` + `await initKeycloak()` before render +- [ ] `Topbar` Sign out → `signOut()` (drop the `/logout` anchor) +- [ ] Dev: `public/config.json` (gitignored) or an injected E2E auth shim for standalone runs +- [ ] Tests updated for the bearer/refresh path + ### Phase 5 — Serve separately (Docker + Helm + CI) -- [ ] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` + - `/logout` `proxy_pass` to key-manager service) -- [ ] `frontend/nginx.conf` +- [ ] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` + `proxy_pass` to key-manager service) +- [ ] `frontend/nginx.conf` — also serves `/config.json` (Helm-rendered, mounted) - [ ] Gut `key-manager/internal/ui/` (remove `embed.go` + `static/`); drop file server / SPA route from `cmd/main.go` → key-manager becomes API-only - [ ] Helm: new `frontend` Deployment + Service (nginx :80, backend URL via env) +- [ ] Helm: render `/config.json` (ConfigMap from `frontend.keycloak.*` values) into nginx - [ ] Repoint `key-manager-nebariapp.yaml` `service:` at the frontend service -- [ ] Add `frontend.image.*` to `values.yaml` +- [ ] Gateway: switch NebariApp auth from cookie injection to a JWT `SecurityPolicy` + validating the bearer (Model B) +- [ ] Add `frontend.image.*` + `frontend.keycloak.*` to `values.yaml` - [ ] CI: `build-frontend` job in `build-images.yaml` - [ ] CI: `lint-frontend` + test job (biome + vitest) in `lint.yaml` / `test.yaml` ### Phase 6 — Local dev -- [ ] Wire `frontend/` into `dev/` (Makefile / manifests) alongside the mock backend -- [ ] Document Vite `/api` proxy + how auth is faked/bypassed locally (cookie middleware - needs a dev shim or a running gateway) +- [ ] Wire `frontend/` into `dev/` (Makefile / manifests) alongside the backend +- [ ] Document Vite `/api` proxy + local `config.json` + how Keycloak/bearer auth is + stubbed for standalone runs (E2E auth shim or a real Keycloak) ### Phase 7 — Quality gate, cleanup, docs - [ ] `npm run build && npm run test:run && npm run check` all pass @@ -150,6 +179,10 @@ Each component gets its own PascalCase dir + `index.ts` barrel. - [ ] Can `NebariApp` route two services under one host? (Would enable the fallback and let us drop the nginx `/api` proxy.) Default assumes **no**. - [ ] Confirm the key-manager ClusterIP service name/port the nginx proxy targets. +- [ ] Confirm the gateway supports a JWT `SecurityPolicy` for the key-manager host + (Model B) and where the Keycloak realm/clientId for the serving pack come from. +- [ ] Decide whether key-manager should validate the bearer signature itself, or keep + trusting the gateway (current middleware does not verify signatures). --- From 383cc3929138515601c757e6660e598c09850618 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 24 Jun 2026 15:03:13 -0400 Subject: [PATCH 06/15] Updates for keycloak auth. --- frontend-rewrite-plan.md | 27 +++--- frontend/package-lock.json | 10 +++ frontend/package.json | 1 + frontend/public/config.json | 7 ++ frontend/src/app/config.ts | 32 +++++++ frontend/src/auth/keycloak.ts | 105 ++++++++++++++++++++++ frontend/src/auth/user.ts | 26 ++++++ frontend/src/components/Topbar/Topbar.tsx | 18 ++-- frontend/src/lib/api.test.ts | 29 +++++- frontend/src/lib/api.ts | 29 ++++-- frontend/src/main.tsx | 6 ++ frontend/src/test/setup.ts | 12 +++ 12 files changed, 272 insertions(+), 30 deletions(-) create mode 100644 frontend/public/config.json create mode 100644 frontend/src/app/config.ts create mode 100644 frontend/src/auth/keycloak.ts create mode 100644 frontend/src/auth/user.ts diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index 410f9e1..f680c48 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -134,18 +134,21 @@ Each component gets its own PascalCase dir + `index.ts` barrel. > Not yet done: visual verification against a **live backend** + Figma comparison. Deferred to > Phase 6 (local dev brings up the API) / Phase 7 (polish). The build, type-check, and tests pass. -### Phase 4b — Auth (Model B: SPA-managed Keycloak) -Mirror nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. -- [ ] Add `keycloak-js` dependency -- [ ] `src/app/config.ts` — load + cache `/config.json` (`keycloak: { url, realm, clientId }`) -- [ ] `src/auth/keycloak.ts` — `initKeycloak()` (`login-required` + PKCE), `getToken()` w/ refresh, - `SessionExpiredError`, `signOut()` → `keycloak.logout()` -- [ ] `src/auth/user.ts` — read identity from the ID token (can replace/augment `useCurrentUser`) -- [ ] `api.ts` — attach `Authorization: Bearer `; on 401 refresh + retry once -- [ ] `main.tsx` — `await loadAppConfig()` + `await initKeycloak()` before render -- [ ] `Topbar` Sign out → `signOut()` (drop the `/logout` anchor) -- [ ] Dev: `public/config.json` (gitignored) or an injected E2E auth shim for standalone runs -- [ ] Tests updated for the bearer/refresh path +### Phase 4b — Auth (Model B: SPA-managed Keycloak) ✅ +Mirrors nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. +- [x] Add `keycloak-js` dependency +- [x] `src/app/config.ts` — load + cache `/config.json` (`keycloak: { url, realm, clientId }`, optional title) +- [x] `src/auth/keycloak.ts` — `initKeycloak()` (`login-required` + PKCE), `getToken()` w/ refresh, + `SessionExpiredError`, `signOut()` → `keycloak.logout()`, `__PW_E2E_AUTH__` shim +- [x] `src/auth/user.ts` — `useUser()` reads identity from the ID token; `Topbar` now uses it +- [x] `api.ts` — attach `Authorization: Bearer `; on 401 refresh + retry once +- [x] `main.tsx` — top-level `await loadAppConfig()` + `await initKeycloak()` before render +- [x] `Topbar` Sign out → `signOut()` (dropped the `/logout` anchor) +- [x] Test setup injects the `__PW_E2E_AUTH__` shim so api calls get a token; `public/config.json` gitignored +- [x] Tests updated/added for the bearer + 401-retry path; gate green (15 tests) + +> Note: `src/hooks/useCurrentUser.ts` (GET /api/me) is no longer used by the UI (identity now +> comes from the ID token) but kept as a valid endpoint wrapper. Remove in cleanup if still unused. ### Phase 5 — Serve separately (Docker + Helm + CI) - [ ] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5dc97b2..3bb7585 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jotai": "^2.10.0", + "keycloak-js": "^26.2.4", "lucide-react": "^0.460.0", "radix-ui": "^1.5.0", "react": "^19.2.0", @@ -6274,6 +6275,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7553957..f77c9e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jotai": "^2.10.0", + "keycloak-js": "^26.2.4", "lucide-react": "^0.460.0", "radix-ui": "^1.5.0", "react": "^19.2.0", diff --git a/frontend/public/config.json b/frontend/public/config.json new file mode 100644 index 0000000..99dd2c2 --- /dev/null +++ b/frontend/public/config.json @@ -0,0 +1,7 @@ +{ + "keycloak": { + "url": "http://localhost:8180", + "realm": "nebari", + "clientId": "nebari-frontend-spa" + } +} diff --git a/frontend/src/app/config.ts b/frontend/src/app/config.ts new file mode 100644 index 0000000..08fb719 --- /dev/null +++ b/frontend/src/app/config.ts @@ -0,0 +1,32 @@ +// Runtime configuration loaded from /config.json at startup. The file is +// rendered by the Helm chart (values.yaml → frontend.keycloak.*) and mounted +// into the nginx container, so realm/clientId change without a rebuild. +// +// Call loadAppConfig() once before the app renders (see main.tsx); afterwards +// use getAppConfig() to read the cached value. + +export type AppConfig = { + keycloak: { url: string; realm: string; clientId: string }; + /** Optional page-title override shown in the browser tab. */ + title?: string; +}; + +let _config: AppConfig | null = null; + +/** Fetch and cache /config.json. The network request happens at most once. */ +export async function loadAppConfig(): Promise { + if (_config) { + return _config; + } + const res = await fetch("/config.json"); + if (!res.ok) { + throw new Error(`Failed to load /config.json: ${res.status}`); + } + _config = (await res.json()) as AppConfig; + return _config; +} + +/** Returns the cached config, or null if loadAppConfig() has not yet resolved. */ +export function getAppConfig(): AppConfig | null { + return _config; +} diff --git a/frontend/src/auth/keycloak.ts b/frontend/src/auth/keycloak.ts new file mode 100644 index 0000000..89a6ada --- /dev/null +++ b/frontend/src/auth/keycloak.ts @@ -0,0 +1,105 @@ +import Keycloak from "keycloak-js"; + +import { loadAppConfig } from "@/app/config"; + +declare global { + interface Window { + // Lets non-production runs (tests, Playwright) inject a fake authenticated + // session instead of redirecting to Keycloak. Never honored in production. + __PW_E2E_AUTH__?: { + authenticated: boolean; + token?: string; + idTokenParsed?: Record; + }; + } +} + +let _keycloak: Keycloak | null = null; + +// A stand-in Keycloak that reports an authenticated session without contacting +// a server — used by the test/E2E shim and the local-dev bypass below. +function fakeSession(token?: string, idTokenParsed?: Record): Keycloak { + return { + authenticated: true, + token, + idTokenParsed, + updateToken: async () => true, + login: async () => {}, + logout: async () => {}, + } as unknown as Keycloak; +} + +/** + * Initialize Keycloak with login-required + PKCE. Resolves once the user is + * authenticated (keycloak-js redirects to the login page if they are not). + * Must be awaited before the app renders. + */ +export async function initKeycloak(): Promise { + if (_keycloak) { + return _keycloak; + } + + const injected = window.__PW_E2E_AUTH__; + if (import.meta.env.MODE !== "production" && injected?.authenticated) { + _keycloak = fakeSession(injected.token, injected.idTokenParsed); + return _keycloak; + } + + const { keycloak: cfg } = await loadAppConfig(); + const kc = new Keycloak({ url: cfg.url, realm: cfg.realm, clientId: cfg.clientId }); + + await kc.init({ + onLoad: "login-required", + pkceMethod: "S256", + checkLoginIframe: false, + }); + + _keycloak = kc; + return kc; +} + +export function getKeycloakInstance(): Keycloak | null { + return _keycloak; +} + +/** + * Thrown when the refresh token has expired and a full re-authentication is + * required. Callers should stop making API calls — a redirect to Keycloak is + * already in flight. + */ +export class SessionExpiredError extends Error { + constructor() { + super("Session expired — redirecting to login"); + this.name = "SessionExpiredError"; + } +} + +/** Returns a valid access token, refreshing it first if it is close to expiry. */ +export async function getToken(): Promise { + if (!_keycloak?.authenticated) { + throw new SessionExpiredError(); + } + + try { + await _keycloak.updateToken(30); + } catch { + _keycloak.login(); + throw new SessionExpiredError(); + } + + const token = _keycloak.token; + if (!token) { + _keycloak.login(); + throw new SessionExpiredError(); + } + + return token; +} + +export function signOut() { + if (_keycloak) { + _keycloak.logout({ redirectUri: `${window.location.origin}/` }); + } else { + window.location.href = "/"; + } +} diff --git a/frontend/src/auth/user.ts b/frontend/src/auth/user.ts new file mode 100644 index 0000000..6204efe --- /dev/null +++ b/frontend/src/auth/user.ts @@ -0,0 +1,26 @@ +import { useMemo } from "react"; + +import { getKeycloakInstance } from "./keycloak"; + +export type User = { + name: string; + email: string; +}; + +/** The authenticated user, read from the Keycloak ID token. */ +export function useUser(): { user: User | null } { + const user = useMemo(() => { + const kc = getKeycloakInstance(); + if (!kc?.authenticated || !kc.idTokenParsed) { + return null; + } + + const parsed = kc.idTokenParsed as Record; + const name = parsed.name || parsed.preferred_username || parsed.sub || "User"; + const email = parsed.email || ""; + + return { name, email }; + }, []); + + return { user }; +} diff --git a/frontend/src/components/Topbar/Topbar.tsx b/frontend/src/components/Topbar/Topbar.tsx index 3a490e1..9b9df8d 100644 --- a/frontend/src/components/Topbar/Topbar.tsx +++ b/frontend/src/components/Topbar/Topbar.tsx @@ -1,7 +1,8 @@ import { ChevronDown, Monitor, Moon, Sun } from "lucide-react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import type { ReactNode } from "react"; - +import { signOut } from "@/auth/keycloak"; +import { useUser } from "@/auth/user"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, @@ -11,17 +12,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useCurrentUser } from "@/hooks/useCurrentUser"; import { isThemeMode, type ThemeMode } from "@/hooks/useThemePreference"; import { userInitials } from "@/lib/format"; import { cn } from "@/lib/utils"; import { useTheme } from "@/providers/ThemeProvider"; export function Topbar() { - const { data: user } = useCurrentUser(); + const { user } = useUser(); const { themeMode, setThemeMode } = useTheme(); - const displayName = user?.name || user?.username || user?.email || "Account"; + const displayName = user?.name || user?.email || "Account"; return (
@@ -38,7 +38,7 @@ export function Topbar() { > - {userInitials(user?.name || user?.username, user?.email)} + {userInitials(user?.name, user?.email)} {displayName} @@ -48,9 +48,7 @@ export function Topbar() {
-

- {user?.name || user?.username || "Signed in"} -

+

{user?.name || "Signed in"}

{user?.email ?

{user.email}

: null}
@@ -77,8 +75,8 @@ export function Topbar() { - - Sign out + signOut()}> + Sign out
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index ab35992..b41751f 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -35,16 +35,41 @@ describe("api", () => { }); }); - it("serializes the request body as JSON", async () => { + it("serializes the request body as JSON and attaches a bearer token", async () => { const fetchMock = mockFetch({ jsonBody: {} }); vi.stubGlobal("fetch", fetchMock); await api.post("/api/x", { a: 1 }); expect(fetchMock).toHaveBeenCalledWith( "/api/x", - expect.objectContaining({ method: "POST", body: JSON.stringify({ a: 1 }) }), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ a: 1 }), + headers: expect.objectContaining({ Authorization: "Bearer test-token" }), + }), ); }); + it("retries once on a 401", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({}), + text: async () => "", + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "", + } as unknown as Response); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.get("/api/x")).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("exposes ApiError as an Error subclass", () => { expect(new ApiError(500, "boom")).toBeInstanceOf(Error); }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9e8c546..38a2e78 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,3 +1,5 @@ +import { getToken } from "@/auth/keycloak"; + /** Error thrown for any non-2xx response, carrying the HTTP status. */ export class ApiError extends Error { readonly status: number; @@ -10,15 +12,30 @@ export class ApiError extends Error { } async function request(method: string, path: string, body?: unknown): Promise { - const opts: RequestInit = { - method, - headers: { "Content-Type": "application/json" }, + // Auth is SPA-managed Keycloak: attach the current access token as a bearer + // on every call. getToken() refreshes it first if it is near expiry. + const exec = async () => { + const token = await getToken(); + const opts: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }; + if (body !== undefined) { + opts.body = JSON.stringify(body); + } + return fetch(path, opts); }; - if (body !== undefined) { - opts.body = JSON.stringify(body); + + // The token can expire between getToken() and the server receiving it; on a + // 401, force a refresh and retry once. + let resp = await exec(); + if (resp.status === 401) { + resp = await exec(); } - const resp = await fetch(path, opts); if (!resp.ok) { const text = await resp.text().catch(() => ""); throw new ApiError(resp.status, `${method} ${path} failed (${resp.status}): ${text.trim()}`); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 137621b..7cbb90d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { initKeycloak } from "@/auth/keycloak"; import { queryClient } from "@/lib/queryClient"; import { ThemeProvider } from "@/providers/ThemeProvider"; @@ -9,6 +10,11 @@ import App from "./App.tsx"; import "./index.css"; +// Authenticate before rendering. initKeycloak() loads /config.json and runs the +// Keycloak login-required flow, only resolving once the user is signed in (or +// immediately, via the dev/E2E bypass). +await initKeycloak(); + const rootElement = document.getElementById("root"); if (!rootElement) { throw new Error("Root element not found"); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 8b8924c..5c9fc06 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,5 +1,7 @@ import "@testing-library/jest-dom/vitest"; +import { initKeycloak } from "@/auth/keycloak"; + // jsdom under this vitest version does not expose a functional Web Storage on // the default origin, and never implements matchMedia. Provide deterministic // in-memory stand-ins so hooks that read theme preference work under test. @@ -46,3 +48,13 @@ if (typeof window.matchMedia !== "function") { }), }); } + +// Inject a fake authenticated Keycloak session so api.ts can attach a bearer +// token without redirecting to a real Keycloak. initKeycloak() honors this shim +// outside production builds. +window.__PW_E2E_AUTH__ = { + authenticated: true, + token: "test-token", + idTokenParsed: { name: "Test User", email: "test@example.com", preferred_username: "test" }, +}; +await initKeycloak(); From 17cad1d8efe9be028c57bc808cc8069c724f6fa3 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Thu, 25 Jun 2026 08:51:44 -0400 Subject: [PATCH 07/15] Handle keycloak local dev. --- dev/Makefile | 19 +++++++- dev/keycloak/realm-nebari.json | 61 ++++++++++++++++++++++++ dev/manifests/keycloak.yaml | 86 ++++++++++++++++++++++++++++++++++ frontend-rewrite-plan.md | 13 +++++ 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 dev/keycloak/realm-nebari.json create mode 100644 dev/manifests/keycloak.yaml diff --git a/dev/Makefile b/dev/Makefile index b5dbd9c..b222b58 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -1,6 +1,6 @@ CLUSTER_NAME ?= llm-serving-test -.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model logs-operator logs-key-manager clean help +.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager deploy-keycloak apply-test-model logs-operator logs-key-manager logs-keycloak pf-key-manager pf-keycloak clean help help: ## Show this help @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @@ -49,6 +49,14 @@ deploy-key-manager: ## Deploy key manager kubectl apply -f manifests/key-manager.yaml kubectl -n llm-operator-system rollout status deployment/llm-key-manager --timeout=60s +deploy-keycloak: ## Deploy dev Keycloak (imports the nebari realm) + kubectl create namespace keycloak --dry-run=client -o yaml | kubectl apply -f - + kubectl create configmap keycloak-realm-import -n keycloak \ + --from-file=realm-nebari.json=keycloak/realm-nebari.json \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f manifests/keycloak.yaml + kubectl -n keycloak rollout status deployment/keycloak --timeout=180s + apply-test-model: ## Apply test model kubectl apply -f manifests/test-model.yaml @@ -58,4 +66,13 @@ logs-operator: ## Tail operator logs logs-key-manager: ## Tail key manager logs kubectl -n llm-operator-system logs -f deployment/llm-key-manager +logs-keycloak: ## Tail Keycloak logs + kubectl -n keycloak logs -f deployment/keycloak + +pf-key-manager: ## Port-forward key-manager API to localhost:8080 + kubectl -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 + +pf-keycloak: ## Port-forward Keycloak to localhost:8180 + kubectl -n keycloak port-forward svc/keycloak 8180:8080 + clean: teardown ## Alias for teardown diff --git a/dev/keycloak/realm-nebari.json b/dev/keycloak/realm-nebari.json new file mode 100644 index 0000000..08edcc0 --- /dev/null +++ b/dev/keycloak/realm-nebari.json @@ -0,0 +1,61 @@ +{ + "realm": "nebari", + "enabled": true, + "sslRequired": "none", + "loginTheme": "keycloak", + "groups": [ + { "name": "llm-users" } + ], + "clients": [ + { + "clientId": "nebari-frontend-spa", + "name": "Nebari LLM Serving Pack UI (dev)", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ + "http://localhost:5173/*", + "http://localhost:8080/*" + ], + "webOrigins": [ + "http://localhost:5173", + "http://localhost:8080" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + }, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "claim.name": "groups", + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "dev", + "enabled": true, + "emailVerified": true, + "email": "dev@example.com", + "firstName": "Dev", + "lastName": "User", + "credentials": [ + { "type": "password", "value": "password", "temporary": false } + ], + "groups": ["/llm-users"], + "realmRoles": ["default-roles-nebari"] + } + ] +} diff --git a/dev/manifests/keycloak.yaml b/dev/manifests/keycloak.yaml new file mode 100644 index 0000000..6c8a94b --- /dev/null +++ b/dev/manifests/keycloak.yaml @@ -0,0 +1,86 @@ +--- +# Dev-only Keycloak for local frontend auth (Model B: SPA-managed Keycloak). +# +# Runs in `start-dev` mode (no TLS, ephemeral H2 DB) and imports the `nebari` +# realm from a ConfigMap. The ConfigMap is rendered from +# dev/keycloak/realm-nebari.json by `make deploy-keycloak` (kept as a standalone +# JSON file so it stays lintable and reusable). NOT for production. +# +# Reach it from the host with: +# kubectl -n keycloak port-forward svc/keycloak 8180:8080 +# which matches frontend/public/config.json (url: http://localhost:8180, +# realm: nebari, clientId: nebari-frontend-spa). Login as testuser / testuser. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + namespace: keycloak + labels: + app: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.2 + args: ["start-dev", "--import-realm"] + env: + # Bootstrap admin for the master realm (KC 26+ env var names). + # Console at http://localhost:8180/admin once port-forwarded. + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: "admin" + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + value: "admin" + # start-dev derives the issuer from the request host, so accessing + # via localhost:8180 yields iss=http://localhost:8180/realms/nebari, + # matching what keycloak-js builds from config.json. + - name: KC_HTTP_ENABLED + value: "true" + ports: + - containerPort: 8080 + name: http + volumeMounts: + - name: realm-import + mountPath: /opt/keycloak/data/import + readOnly: true + readinessProbe: + httpGet: + path: /realms/nebari + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 30 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + volumes: + - name: realm-import + configMap: + name: keycloak-realm-import +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + namespace: keycloak + labels: + app: keycloak +spec: + selector: + app: keycloak + ports: + - name: http + port: 8080 + targetPort: 8080 + protocol: TCP diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index f680c48..1841892 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -166,10 +166,23 @@ Mirrors nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. - [ ] CI: `lint-frontend` + test job (biome + vitest) in `lint.yaml` / `test.yaml` ### Phase 6 — Local dev +- [x] Dev Keycloak in the kind cluster: `dev/manifests/keycloak.yaml` (`start-dev + --import-realm`) + `dev/keycloak/realm-nebari.json` (realm `nebari`, public + PKCE client `nebari-frontend-spa`, groups mapper, `testuser`/`testuser`); + `make deploy-keycloak` renders the realm ConfigMap + deploys; `make + pf-keycloak` (8180) / `pf-key-manager` (8080) port-forward helpers - [ ] Wire `frontend/` into `dev/` (Makefile / manifests) alongside the backend - [ ] Document Vite `/api` proxy + local `config.json` + how Keycloak/bearer auth is stubbed for standalone runs (E2E auth shim or a real Keycloak) +> Inner loop: `cd dev && make setup build-images load-images deploy deploy-keycloak`, +> then `make pf-keycloak` + `make pf-key-manager` (separate terminals), then +> `cd frontend && npm run dev`. `frontend/public/config.json` already points at +> `http://localhost:8180` / realm `nebari` / client `nebari-frontend-spa`. Mint a +> token without the browser via the client's direct-access grant: +> `curl -d client_id=nebari-frontend-spa -d username=testuser -d password=testuser +> -d grant_type=password http://localhost:8180/realms/nebari/protocol/openid-connect/token` + ### Phase 7 — Quality gate, cleanup, docs - [ ] `npm run build && npm run test:run && npm run check` all pass - [ ] Remove old vanilla files only after parity confirmed From a91194572ebd24e0e46d6b0f7173f822b0abb84a Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Tue, 7 Jul 2026 09:58:06 -0400 Subject: [PATCH 08/15] Dev env updates. --- README.md | 18 ++++++++-- dev/Makefile | 66 +++++++++++++++++++---------------- dev/run-dev.sh | 59 +++++++++++++++++++++++-------- frontend/src/auth/keycloak.ts | 15 ++++++++ 4 files changed, 111 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 2f0164d..d2eaec1 100644 --- a/README.md +++ b/README.md @@ -263,8 +263,8 @@ make deploy # Apply a test model make apply-test-model -# Watch reconciliation -kubectl -n llm-serving get llmmodels -w +# Watch reconciliation (models live in the operator's namespace) +kubectl -n llm-operator-system get llmmodels -w # Tail logs make logs-operator @@ -274,11 +274,25 @@ make logs-key-manager make teardown ``` +### Key manager UI + +The key manager web UI is a [React](https://react.dev) + TypeScript app (Vite, Tailwind, shadcn/ui) in [`frontend/`](frontend/). For a one-command dev loop that needs **no Keycloak**: + +```bash +# One-time: copy dev/.env.example to dev/.env and set OPENROUTER_API_KEY +cd dev && make run-dev +``` + +This idempotently brings up the kind cluster, deploys the operator and a **dev-mode** key manager (auth bypassed, a fixed `dev` identity injected — see `LLM_DEV_MODE`), applies a few OpenRouter passthrough models so the list is populated, port-forwards the key-manager API to `localhost:8080`, and starts the Vite dev server at **http://localhost:5173** with hot reload. Edit files under `frontend/src/` and the browser updates live — there is no build step and no login. Press `Ctrl-C` to stop (the cluster is left running for the next run). + +Requires [Node.js](https://nodejs.org) + npm and an [OpenRouter API key](https://openrouter.ai/keys). To exercise the real Keycloak login flow instead of the bypass, deploy Keycloak (`make deploy-keycloak && make pf-keycloak`) and run the UI without the dev flag (`cd frontend && npm run dev`). + Run tests directly: ```bash cd operator && make test cd key-manager && go test ./... +cd frontend && npm test ``` ### Documentation site diff --git a/dev/Makefile b/dev/Makefile index 4d4f036..aa17d53 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -1,5 +1,11 @@ CLUSTER_NAME ?= llm-serving-test +# Pin every kubectl to the local kind cluster's context. Without this, the dev +# targets act on whatever your current kube-context happens to be - which could +# be a remote/production cluster - and would deploy the auth-bypassing dev-mode +# key-manager there. kind names its context "kind-". +KUBECTL := kubectl --context kind-$(CLUSTER_NAME) + # Dependency versions. These move together: Envoy AI Gateway v0.5.x requires # Envoy Gateway v1.6.x and Gateway API v1.4.0 (see # https://aigateway.envoyproxy.io/docs/compatibility/). Bump them as a set @@ -31,30 +37,30 @@ setup: ## Create kind cluster and install dependencies # died midway can be re-run without `make teardown` first. @kind get clusters | grep -qx $(CLUSTER_NAME) || kind create cluster --name $(CLUSTER_NAME) # cert-manager - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/$(CERT_MANAGER_VERSION)/cert-manager.yaml - kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=120s + $(KUBECTL) apply -f https://github.com/cert-manager/cert-manager/releases/download/$(CERT_MANAGER_VERSION)/cert-manager.yaml + $(KUBECTL) -n cert-manager rollout status deployment/cert-manager-webhook --timeout=120s # Gateway API CRDs. Server-side apply so the field ownership matches the # eg chart's own SSA on Helm 4, letting it cleanly take co-ownership with # --force-conflicts (see HELM_FORCE_CONFLICTS above) instead of erroring. - kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml + $(KUBECTL) apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml # GIE CRDs (includes the graduated inference.networking.k8s.io group) - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/$(GIE_VERSION)/manifests.yaml + $(KUBECTL) apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/$(GIE_VERSION)/manifests.yaml # Envoy AI Gateway first: envoy-gateway's extensionManager below points at # the ai-gateway-controller XDS service, so bring it up before reconfiguring # envoy-gateway to avoid noisy connection-refused logs during translation. helm upgrade -i aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm --version $(AI_GATEWAY_VERSION) -n envoy-ai-gateway-system --create-namespace helm upgrade -i aieg oci://docker.io/envoyproxy/ai-gateway-helm --version $(AI_GATEWAY_VERSION) -n envoy-ai-gateway-system - kubectl -n envoy-ai-gateway-system wait --timeout=120s deployment/ai-gateway-controller --for=condition=Available + $(KUBECTL) -n envoy-ai-gateway-system wait --timeout=120s deployment/ai-gateway-controller --for=condition=Available # Envoy Gateway, wired with the AI Gateway ext_proc extension (enableBackend, # extensionManager, backendResources). Without this the per-model routing # layer 404s and passthrough upstreams never get a TLS transport socket. helm upgrade -i eg oci://docker.io/envoyproxy/gateway-helm --version $(ENVOY_GATEWAY_VERSION) -n envoy-gateway-system --create-namespace $(HELM_FORCE_CONFLICTS) -f eg-extension-values.yaml - kubectl -n envoy-gateway-system rollout status deployment/envoy-gateway --timeout=120s + $(KUBECTL) -n envoy-gateway-system rollout status deployment/envoy-gateway --timeout=120s # CRDs (served LLMModels and external-provider PassthroughModels) - kubectl apply -f ../charts/nebari-llm-serving/crds/llmmodel-crd.yaml - kubectl apply -f ../charts/nebari-llm-serving/crds/passthroughmodel-crd.yaml + $(KUBECTL) apply -f ../charts/nebari-llm-serving/crds/llmmodel-crd.yaml + $(KUBECTL) apply -f ../charts/nebari-llm-serving/crds/passthroughmodel-crd.yaml # Test Gateways - kubectl apply -f gateways.yaml + $(KUBECTL) apply -f gateways.yaml teardown: ## Delete kind cluster kind delete cluster --name $(CLUSTER_NAME) @@ -70,52 +76,52 @@ load-images: ## Load images into kind deploy: deploy-operator deploy-key-manager ## Deploy everything deploy-operator: ## Deploy operator - kubectl apply -f manifests/operator.yaml - kubectl apply -f manifests/cert-manager-config.yaml - kubectl apply -f manifests/webhook.yaml - kubectl -n llm-operator-system rollout status deployment/llm-operator --timeout=60s + $(KUBECTL) apply -f manifests/operator.yaml + $(KUBECTL) apply -f manifests/cert-manager-config.yaml + $(KUBECTL) apply -f manifests/webhook.yaml + $(KUBECTL) -n llm-operator-system rollout status deployment/llm-operator --timeout=60s deploy-key-manager: ## Deploy key manager (dev mode: auth bypassed, dev identity injected) - kubectl apply -f manifests/key-manager.yaml - kubectl -n llm-operator-system rollout status deployment/llm-key-manager --timeout=60s + $(KUBECTL) apply -f manifests/key-manager.yaml + $(KUBECTL) -n llm-operator-system rollout status deployment/llm-key-manager --timeout=60s deploy-keycloak: ## Deploy dev Keycloak (imports the nebari realm) - kubectl create namespace keycloak --dry-run=client -o yaml | kubectl apply -f - - kubectl create configmap keycloak-realm-import -n keycloak \ + $(KUBECTL) create namespace keycloak --dry-run=client -o yaml | $(KUBECTL) apply -f - + $(KUBECTL) create configmap keycloak-realm-import -n keycloak \ --from-file=realm-nebari.json=keycloak/realm-nebari.json \ - --dry-run=client -o yaml | kubectl apply -f - - kubectl apply -f manifests/keycloak.yaml - kubectl -n keycloak rollout status deployment/keycloak --timeout=180s + --dry-run=client -o yaml | $(KUBECTL) apply -f - + $(KUBECTL) apply -f manifests/keycloak.yaml + $(KUBECTL) -n keycloak rollout status deployment/keycloak --timeout=180s apply-test-model: ## Apply served test model (mock vLLM) - kubectl apply -f manifests/test-model.yaml + $(KUBECTL) apply -f manifests/test-model.yaml create-openrouter-secret: ## Create the OpenRouter provider credential (OPENROUTER_API_KEY env var required) @test -n "$(OPENROUTER_API_KEY)" || { echo "set OPENROUTER_API_KEY"; exit 1; } - kubectl -n llm-operator-system create secret generic openrouter-api-key \ + $(KUBECTL) -n llm-operator-system create secret generic openrouter-api-key \ --from-literal=apiKey="$(OPENROUTER_API_KEY)" \ - --dry-run=client -o yaml | kubectl apply -f - + --dry-run=client -o yaml | $(KUBECTL) apply -f - apply-passthrough-model: ## Apply the OpenRouter PassthroughModel (run create-openrouter-secret first) - kubectl apply -f manifests/passthrough-test-model.yaml + $(KUBECTL) apply -f manifests/passthrough-test-model.yaml ui: ## Port-forward the key-manager UI to http://localhost:8080 (dev mode, no login) @echo "key-manager UI at http://localhost:8080 (dev mode injects user 'dev')" - kubectl -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 + $(KUBECTL) -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 logs-operator: ## Tail operator logs - kubectl -n llm-operator-system logs -f deployment/llm-operator + $(KUBECTL) -n llm-operator-system logs -f deployment/llm-operator logs-key-manager: ## Tail key manager logs - kubectl -n llm-operator-system logs -f deployment/llm-key-manager + $(KUBECTL) -n llm-operator-system logs -f deployment/llm-key-manager logs-keycloak: ## Tail Keycloak logs - kubectl -n keycloak logs -f deployment/keycloak + $(KUBECTL) -n keycloak logs -f deployment/keycloak pf-key-manager: ## Port-forward key-manager API to localhost:8080 - kubectl -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 + $(KUBECTL) -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 pf-keycloak: ## Port-forward Keycloak to localhost:8180 - kubectl -n keycloak port-forward svc/keycloak 8180:8080 + $(KUBECTL) -n keycloak port-forward svc/keycloak 8180:8080 clean: teardown ## Alias for teardown diff --git a/dev/run-dev.sh b/dev/run-dev.sh index d27ffac..35f39b5 100755 --- a/dev/run-dev.sh +++ b/dev/run-dev.sh @@ -15,6 +15,10 @@ NS=llm-operator-system KM_PORT="${KM_PORT:-8080}" UI_PORT="${UI_PORT:-5173}" +# Pin kubectl to the kind cluster's context so this script can never act on +# whatever the current context happens to be (e.g. a remote/production cluster). +KUBECTL="kubectl --context kind-${CLUSTER_NAME}" + # --- load .env ------------------------------------------------------------- if [[ -f .env ]]; then set -a; . ./.env; set +a @@ -28,10 +32,28 @@ fi if ! kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then echo "==> kind cluster '$CLUSTER_NAME' not found; running full setup (a few minutes)..." make setup +else + # The cluster is registered, but its node container(s) may be stopped (e.g. + # after a Docker Desktop restart or OOM). `kind get clusters` still lists it, + # so it slips past the check above and later kubectl / `kind load` calls fail + # against a dead container. Start any stopped nodes and wait for the API. + stopped="$(docker ps -aq \ + --filter "label=io.x-k8s.kind.cluster=${CLUSTER_NAME}" \ + --filter "status=exited" --filter "status=created" 2>/dev/null || true)" + if [[ -n "$stopped" ]]; then + echo "==> kind cluster '$CLUSTER_NAME' has stopped node(s); starting them..." + # shellcheck disable=SC2086 + docker start $stopped >/dev/null + echo "==> waiting for the Kubernetes API to become ready..." + for _ in $(seq 1 30); do + if $KUBECTL get --raw='/readyz' >/dev/null 2>&1; then break; fi + sleep 2 + done + fi fi # --- 2. operator + key-manager -------------------------------------------- -if ! kubectl -n "$NS" get deploy/llm-key-manager >/dev/null 2>&1; then +if ! $KUBECTL -n "$NS" get deploy/llm-key-manager >/dev/null 2>&1; then echo "==> building images and deploying operator + key-manager..." make build-images make load-images @@ -42,25 +64,25 @@ fi # --- 3. provider credential + models -------------------------------------- echo "==> applying OpenRouter credential and dev models..." -kubectl -n "$NS" create secret generic openrouter-api-key \ +$KUBECTL -n "$NS" create secret generic openrouter-api-key \ --from-literal=apiKey="$OPENROUTER_API_KEY" \ - --dry-run=client -o yaml | kubectl apply -f - >/dev/null + --dry-run=client -o yaml | $KUBECTL apply -f - >/dev/null # The operator's validating webhook gates PassthroughModel creates and isn't # guaranteed to be serving the instant `make deploy`'s rollout returns (it waits # on the cert mount). Retry so a momentary "connection refused" doesn't trip # `set -e` and abort the whole run. for attempt in $(seq 1 30); do - kubectl apply -f manifests/dev-models.yaml >/dev/null 2>&1 && break + $KUBECTL apply -f manifests/dev-models.yaml >/dev/null 2>&1 && break if [[ $attempt -eq 30 ]]; then echo "ERROR: operator webhook never became ready" >&2 - kubectl apply -f manifests/dev-models.yaml >&2 || true + $KUBECTL apply -f manifests/dev-models.yaml >&2 || true exit 1 fi echo "==> operator webhook not ready yet, retrying ($attempt)..." sleep 2 done for m in claude-sonnet-45 gemini-25-flash llama-33-70b; do - kubectl -n "$NS" wait passthroughmodel/$m --for=jsonpath='{.status.phase}'=Ready --timeout=90s + $KUBECTL -n "$NS" wait passthroughmodel/$m --for=jsonpath='{.status.phase}'=Ready --timeout=90s done # --- 4. port-forward + UI dev server -------------------------------------- @@ -76,7 +98,7 @@ trap cleanup EXIT INT TERM # crashed prior run and make the readiness check below pass instantly. PF_LOG="$(mktemp)" echo "==> port-forwarding key-manager to localhost:${KM_PORT}..." -kubectl -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >"$PF_LOG" 2>&1 & +$KUBECTL -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >"$PF_LOG" 2>&1 & PF_PID=$! for _ in $(seq 1 20); do if grep -q "Forwarding from" "$PF_LOG" 2>/dev/null; then break; fi @@ -84,14 +106,21 @@ for _ in $(seq 1 20); do done echo -echo " key-manager (embedded UI + API): http://localhost:${KM_PORT}" -echo " hot-reload UI dev server: http://localhost:${UI_PORT} <-- develop here" -echo " edit key-manager/internal/ui/static/* and the browser reloads automatically." +echo " key-manager API (dev mode, no auth): http://localhost:${KM_PORT}" +echo " React UI dev server (hot reload): http://localhost:${UI_PORT} <-- develop here" +echo " edit frontend/src/* and Vite hot-reloads the browser." echo " Ctrl-C to stop." echo -# Foreground: exits on Ctrl-C, which triggers cleanup of the port-forward. -( cd uidev && go run . \ - -static ../../key-manager/internal/ui/static \ - -api "http://localhost:${KM_PORT}" \ - -addr ":${UI_PORT}" ) +# First run needs the frontend deps installed. +if [[ ! -d ../frontend/node_modules ]]; then + echo "==> installing frontend dependencies (first run)..." + npm --prefix ../frontend install +fi + +# Foreground: the Vite dev server. Exits on Ctrl-C, which triggers cleanup of +# the port-forward. VITE_DEV_NO_AUTH bypasses the Keycloak login redirect to +# match the key-manager's LLM_DEV_MODE, so no Keycloak is required. Vite proxies +# /api to the port-forwarded key-manager (WEBAPI_URL; see frontend/vite.config.ts). +VITE_DEV_NO_AUTH=true WEBAPI_URL="http://localhost:${KM_PORT}" \ + npm --prefix ../frontend run dev -- --port "${UI_PORT}" --strictPort diff --git a/frontend/src/auth/keycloak.ts b/frontend/src/auth/keycloak.ts index 89a6ada..a9dfeac 100644 --- a/frontend/src/auth/keycloak.ts +++ b/frontend/src/auth/keycloak.ts @@ -45,6 +45,21 @@ export async function initKeycloak(): Promise { return _keycloak; } + // Local-dev bypass mirroring the key-manager's LLM_DEV_MODE: skip Keycloak + // entirely and run as a fixed "dev" identity (the same one the backend + // injects). Enabled with VITE_DEV_NO_AUTH=true, which `make run-dev` sets so + // the UI runs against a Keycloak-free local cluster. Never honored in a + // production build. + if (import.meta.env.MODE !== "production" && import.meta.env.VITE_DEV_NO_AUTH === "true") { + _keycloak = fakeSession("dev", { + name: "dev", + preferred_username: "dev", + email: "dev@local", + sub: "dev", + }); + return _keycloak; + } + const { keycloak: cfg } = await loadAppConfig(); const kc = new Keycloak({ url: cfg.url, realm: cfg.realm, clientId: cfg.clientId }); From 2c175bdfa78499fa93bcd77e7d356181c3ca96d1 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Tue, 7 Jul 2026 10:56:25 -0400 Subject: [PATCH 09/15] Migrate to nebari design. --- frontend/components.json | 4 +- frontend/package-lock.json | 99 +++++- frontend/package.json | 4 +- frontend/public/nebari-logo_dark.svg | 24 ++ .../CreateKeyDialog/CreateKeyDialog.test.tsx | 107 ++++++ .../CreateKeyDialog/CreateKeyDialog.tsx | 32 +- frontend/src/components/KeysCard/KeysCard.tsx | 2 +- frontend/src/components/Topbar/Topbar.tsx | 5 +- frontend/src/components/ui/alert.tsx | 78 +++-- frontend/src/components/ui/badge.tsx | 57 ++-- frontend/src/components/ui/button.tsx | 138 +++++--- frontend/src/components/ui/card.tsx | 73 +++-- frontend/src/components/ui/dialog.tsx | 171 ++++++---- frontend/src/components/ui/dropdown-menu.tsx | 2 +- frontend/src/components/ui/input.tsx | 46 ++- frontend/src/components/ui/label.tsx | 24 +- frontend/src/components/ui/select.tsx | 167 +++++----- frontend/src/components/ui/spinner.tsx | 51 +++ frontend/src/index.css | 306 +++++++++++------- 19 files changed, 990 insertions(+), 400 deletions(-) create mode 100644 frontend/public/nebari-logo_dark.svg create mode 100644 frontend/src/components/CreateKeyDialog/CreateKeyDialog.test.tsx create mode 100644 frontend/src/components/ui/spinner.tsx diff --git a/frontend/components.json b/frontend/components.json index dfe0f2d..a6d3507 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -21,5 +21,7 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": { + "@nebari": "https://nebari-dev.github.io/nebari-design/r/{name}.json" + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bb7585..f3fcf4f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,9 @@ "name": "nebari-llm-serving-frontend", "version": "0.0.0", "dependencies": { - "@fontsource-variable/inter": "^5.2.8", + "@base-ui-components/react": "^1.0.0-rc.0", + "@fontsource-variable/geist": "^5.2.9", + "@fontsource/ibm-plex-mono": "^5.2.7", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", @@ -494,7 +496,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -545,6 +546,62 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui-components/react": { + "version": "1.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@base-ui-components/react/-/react-1.0.0-rc.0.tgz", + "integrity": "sha512-9lhUFbJcbXvc9KulLev1WTFxS/alJRBWDH/ibKSQaNvmDwMFS2gKp1sTeeldYSfKuS/KC1w2MZutc0wHu2hRHQ==", + "deprecated": "Package was renamed to @base-ui/react", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@base-ui-components/utils": "0.2.2", + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "tabbable": "^6.3.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui-components/utils": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@base-ui-components/utils/-/utils-0.2.2.tgz", + "integrity": "sha512-rNJCD6TFy3OSRDKVHJDzLpxO3esTV1/drRtWNUpe7rCpPN9HZVHUCuP+6rdDYDGWfXnQHbqi05xOyRP2iZAlkw==", + "deprecated": "Package was renamed to @base-ui/utils", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@floating-ui/utils": "^0.2.10", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@biomejs/biome": { "version": "2.4.16", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", @@ -1526,10 +1583,19 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@fontsource-variable/inter": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", - "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -7652,6 +7718,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8249,6 +8321,12 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -8597,6 +8675,15 @@ } } }, + "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/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f77c9e7..5cb2c6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,9 @@ "test": "vitest --run" }, "dependencies": { - "@fontsource-variable/inter": "^5.2.8", + "@base-ui-components/react": "^1.0.0-rc.0", + "@fontsource-variable/geist": "^5.2.9", + "@fontsource/ibm-plex-mono": "^5.2.7", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", diff --git a/frontend/public/nebari-logo_dark.svg b/frontend/public/nebari-logo_dark.svg new file mode 100644 index 0000000..6b5bb0c --- /dev/null +++ b/frontend/public/nebari-logo_dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/CreateKeyDialog/CreateKeyDialog.test.tsx b/frontend/src/components/CreateKeyDialog/CreateKeyDialog.test.tsx new file mode 100644 index 0000000..f7574e7 --- /dev/null +++ b/frontend/src/components/CreateKeyDialog/CreateKeyDialog.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createStore, Provider as JotaiProvider } from "jotai"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ThemeProvider } from "@/providers/ThemeProvider"; +import { dialogAtom } from "@/store/dialogAtoms"; +import { CreateKeyDialog } from "./CreateKeyDialog"; + +const models = [ + { Name: "llama-3", Namespace: "team-a" }, + { Name: "mistral", Namespace: "" }, +]; + +/** Stub fetch for the two endpoints the dialog touches: model list + create. */ +function stubFetch() { + const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => { + const method = opts?.method ?? "GET"; + if (url.includes("/api/models")) { + return { + ok: true, + status: 200, + json: async () => ({ models }), + text: async () => "", + } as Response; + } + if (url.includes("/api/keys") && method === "POST") { + return { + ok: true, + status: 200, + json: async () => ({ clientId: "cid-1", apiKey: "sk-test" }), + text: async () => "", + } as Response; + } + return { ok: false, status: 404, json: async () => ({}), text: async () => "" } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +/** Render the dialog already open (dialogAtom = create) inside app providers. */ +function renderOpen() { + const store = createStore(); + store.set(dialogAtom, { type: "create" }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } + + return render(, { wrapper: Wrapper }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("CreateKeyDialog (Nebari Base UI Select)", () => { + it("opens the model select, lists namespaced models, and reflects the choice", async () => { + stubFetch(); + const user = userEvent.setup(); + renderOpen(); + + expect(screen.getByText("Create API Key")).toBeInTheDocument(); + + const trigger = screen.getByRole("combobox", { name: "Model" }); + expect(trigger).toHaveTextContent("Select a model"); + + await user.click(trigger); + const listbox = await screen.findByRole("listbox"); + // Labels are the {namespace}/{name} form derived in the dialog. + await within(listbox).findByText("team-a/llama-3"); + expect(within(listbox).getByText("mistral")).toBeInTheDocument(); + + await user.click(within(listbox).getByText("team-a/llama-3")); + await waitFor(() => expect(trigger).toHaveTextContent("team-a/llama-3")); + }); + + it("submits the selected model's value (not its label) to the create endpoint", async () => { + const fetchMock = stubFetch(); + const user = userEvent.setup(); + renderOpen(); + + await user.click(await screen.findByRole("combobox", { name: "Model" })); + const listbox = await screen.findByRole("listbox"); + await user.click(await within(listbox).findByText("team-a/llama-3")); + + await user.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + const post = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes("/api/keys") && opts?.method === "POST", + ); + expect(post).toBeDefined(); + expect(JSON.parse(String(post?.[1]?.body))).toMatchObject({ modelName: "llama-3" }); + }); + }); +}); diff --git a/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx b/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx index 57ea12c..03a3641 100644 --- a/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx +++ b/frontend/src/components/CreateKeyDialog/CreateKeyDialog.tsx @@ -33,6 +33,14 @@ export function CreateKeyDialog() { const open = dialog.type === "create"; + // Base UI's Select renders the raw value unless given a label mapping, so we + // derive {label, value} items once and reuse them for both the options and + // the trigger's value→label lookup. + const modelItems = (models ?? []).map((model) => ({ + value: model.name, + label: model.namespace ? `${model.namespace}/${model.name}` : model.name, + })); + function close() { setDialog({ type: "none" }); setModelName(""); @@ -70,14 +78,22 @@ export function CreateKeyDialog() {
- setModelName((value as string | null) ?? "")} + > - + + {(value) => + modelItems.find((item) => item.value === value)?.label ?? "Select a model" + } + - {(models ?? []).map((model) => ( - - {model.namespace ? `${model.namespace}/${model.name}` : model.name} + {modelItems.map((item) => ( + + {item.label} ))} @@ -95,13 +111,15 @@ export function CreateKeyDialog() { />
- {fieldError ?

{fieldError}

: null} + {fieldError ?

{fieldError}

: null} - diff --git a/frontend/src/components/KeysCard/KeysCard.tsx b/frontend/src/components/KeysCard/KeysCard.tsx index a331a74..130700f 100644 --- a/frontend/src/components/KeysCard/KeysCard.tsx +++ b/frontend/src/components/KeysCard/KeysCard.tsx @@ -35,7 +35,7 @@ export function KeysCard({ className }: { className?: string }) { {isLoading ? (

Loading keys…

) : isError ? ( -

+

Failed to load keys: {error instanceof Error ? error.message : "unknown error"}

) : !keys || keys.length === 0 ? ( diff --git a/frontend/src/components/Topbar/Topbar.tsx b/frontend/src/components/Topbar/Topbar.tsx index 9b9df8d..a051711 100644 --- a/frontend/src/components/Topbar/Topbar.tsx +++ b/frontend/src/components/Topbar/Topbar.tsx @@ -24,9 +24,10 @@ export function Topbar() { const displayName = user?.name || user?.email || "Account"; return ( -
+
- Nebari + Nebari + Nebari diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx index ff40e63..9226c4e 100644 --- a/frontend/src/components/ui/alert.tsx +++ b/frontend/src/components/ui/alert.tsx @@ -1,16 +1,28 @@ import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; - import { cn } from "@/lib/utils"; const alertVariants = cva( - "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + // CSS-grid layout from the Figma frame: an optional 16px icon column and a + // 1fr content column. With no leading `svg` the icon column collapses to 0 so + // the content sits flush left. A leading icon is auto-placed in column 1 and + // nudged down half a line to align with the title's cap height. An + // `AlertAction` floats in the top-right corner, so reserve trailing space for + // it whenever one is present. + "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-md border p-2 text-sm has-[>svg]:grid-cols-[1rem_1fr] has-[>svg]:gap-x-2 has-data-[slot=alert-action]:pr-18 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", { variants: { + // Maps onto the Figma `Alert` variant set. `default` is the neutral card + // style and also carries informational messages; `destructive` doubles as + // the "error" state. Colored variants (success, warning, destructive) + // paint title, description, icon, and border with the same foreground + // token; only `default` mutes its description. variant: { - default: "bg-card text-card-foreground", - destructive: - "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + default: + "border-border bg-card text-foreground *:data-[slot=alert-description]:text-muted-foreground", + success: "border-success-foreground bg-success text-success-foreground", + warning: "border-warning-foreground bg-warning text-warning-foreground", + destructive: "border-destructive-foreground bg-destructive text-destructive-foreground", }, }, defaultVariants: { @@ -19,15 +31,34 @@ const alertVariants = cva( }, ); -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { +type AlertProps = React.ComponentProps<"div"> & VariantProps; + +// Map severity onto the ARIA live-region role. `alert` (assertive) interrupts +// the screen reader immediately and is reserved for variants that demand +// attention — `warning`/`destructive`. The calmer `success`/`default` +// variants use `status` (polite) so they're announced without cutting off +// whatever the user is doing. Callers can override with an explicit `role`. +const alertRoleForVariant: Record, "alert" | "status"> = { + default: "status", + success: "status", + warning: "alert", + destructive: "alert", +}; + +/** + * Alert surfaces an inline, non-blocking status message, implemented from the + * Nebari Figma `Alert` variant set. Compose it with {@link AlertTitle}, + * {@link AlertDescription}, and an optional {@link AlertAction}; drop a + * `lucide-react` icon as the first child to get the leading-icon layout. The + * root is a live region — `role="alert"` (assertive) for `warning`/`destructive` + * and `role="status"` (polite) otherwise — overridable via the `role` prop. + */ +function Alert({ className, variant, role, ...props }: AlertProps) { return (
@@ -36,14 +67,7 @@ function Alert({ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { return ( -
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", - className, - )} - {...props} - /> +
); } @@ -52,7 +76,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
) ); } +/** + * Action slot pinned to the top-right corner of the {@link Alert} — typically a + * dismiss icon button or a short action button. The root reserves trailing + * padding whenever an `AlertAction` is present so it never overlaps the content. + */ function AlertAction({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
); } -export { Alert, AlertAction, AlertDescription, AlertTitle }; +export type { AlertProps }; +export { Alert, AlertAction, AlertDescription, AlertTitle, alertVariants }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 17e7844..f5a6890 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -1,21 +1,22 @@ +import { useRender } from "@base-ui-components/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { Slot } from "radix-ui"; -import type * as React from "react"; - import { cn } from "@/lib/utils"; const badgeVariants = cva( - "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + // Interaction cues (hover underline, hover/active fill) are scoped to when the + // badge is actually rendered as a link or button (`[a&]` / `[button&]`); a + // plain status/label chip stays static. `underline-offset-2` + the wider `px-2` + // padding keep the hover underline inside the chip's bounds. + "inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs leading-4 underline-offset-2 outline-none transition-colors [a&]:hover:underline [button&]:hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3", { variants: { variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", - destructive: - "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", - outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", - ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", - link: "text-primary underline-offset-4 hover:underline", + default: + "bg-primary text-primary-foreground [a&]:hover:bg-primary-hover [a&]:active:bg-primary-hover [button&]:hover:bg-primary-hover [button&]:active:bg-primary-hover", + secondary: "bg-secondary text-secondary-foreground", + destructive: "bg-destructive text-destructive-foreground", + outline: "border-border-strong bg-background text-foreground", + ghost: "text-foreground", }, }, defaultVariants: { @@ -24,22 +25,26 @@ const badgeVariants = cva( }, ); -function Badge({ - className, - variant = "default", - asChild = false, - ...props -}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "span"; +interface BadgeProps extends useRender.ComponentProps<"span">, VariantProps {} - return ( - - ); +/** + * Badge implemented from the Nebari Figma spec — a small status/label chip. + * Variants are driven by `class-variance-authority`; polymorphism is provided + * by Base UI's `render` prop, so a `Badge` can become a link (or any element) + * while keeping its styling (`}>`). + */ +function Badge({ className, variant, ref, render = , ...props }: BadgeProps) { + return useRender({ + render, + ref, + props: { + className: cn(badgeVariants({ variant }), className), + "data-slot": "badge", + "data-variant": variant ?? "default", + ...props, + }, + }); } +export type { BadgeProps }; export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 417f33f..e70f9cc 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,37 +1,38 @@ +import { useRender } from "@base-ui-components/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { Slot } from "radix-ui"; -import type * as React from "react"; - +import { Children, isValidElement, type ReactNode } from "react"; +import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium underline-offset-4 outline-none motion-safe:transition-[color,background-color,border-color,opacity,transform] motion-safe:duration-[--duration-fast] motion-safe:ease-[--ease-standard] motion-safe:active:scale-[0.97] hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground data-[disabled]:no-underline data-[disabled]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { + // Disabled and loading collapse to a muted look (Figma): the component + // sets `data-disabled` whenever `disabled || loading`, so both states + // share these `data-[disabled]:*` overrides and loading also shows a Spinner. variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover active:bg-primary-hover data-[disabled]:bg-muted", + destructive: + "border border-transparent bg-destructive text-destructive-foreground hover:border-destructive-foreground active:border-destructive-foreground data-[disabled]:border-transparent data-[disabled]:bg-muted", outline: - "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + "border border-input bg-background shadow-xs hover:border-muted-foreground hover:bg-accent hover:text-accent-foreground active:border-muted-foreground active:bg-accent active:text-accent-foreground data-[disabled]:border-border data-[disabled]:bg-transparent", secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + "border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:border-input active:border-input data-[disabled]:border-transparent data-[disabled]:bg-muted", ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", + "hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground", + link: "text-foreground", }, size: { - default: - "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", - lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", - icon: "size-9", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", - "icon-lg": "size-10", + xs: "h-6 gap-1 rounded-md px-2 text-xs [&_svg:not([class*='size-'])]:size-3.5", + sm: "h-7 gap-1.5 rounded-md px-2.5 text-xs [&_svg:not([class*='size-'])]:size-3.5", + default: "h-8 px-3 text-sm", + lg: "h-9 px-4 text-sm", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3.5", + "icon-sm": "size-7 [&_svg:not([class*='size-'])]:size-3.5", + icon: "size-8 text-sm", + "icon-lg": "size-9 text-sm", }, }, defaultVariants: { @@ -41,27 +42,86 @@ const buttonVariants = cva( }, ); +type ButtonProps = useRender.ComponentProps<"button"> & + VariantProps & { + /** + * Renders a {@link Spinner}, sets `aria-busy`, and disables the button + * while an async action is in flight. The Spinner replaces the leading + * icon (or the whole content, for icon-only sizes). + */ + loading?: boolean; + /** + * Optional label shown beside the Spinner while `loading`, replacing the + * button's normal content (e.g. `loadingText="Saving…"`). Ignored for + * icon-only sizes. + */ + loadingText?: ReactNode; + }; + +/** + * Button implemented from the Nebari Figma spec. Variants and sizes are driven + * by `class-variance-authority`; polymorphism is provided by Base UI's `render` + * prop, so a `Button` can become a link or any other element while keeping its + * styling (` - - )} - + + )} + + ); } +/** Button that closes the dialog. */ +function DialogClose(props: DialogPrimitive.Close.Props) { + return ; +} + +/** Layout wrapper for dialog title and description. */ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
); } -function DialogFooter({ - className, - showCloseButton = false, - children, - ...props -}: React.ComponentProps<"div"> & { - showCloseButton?: boolean; -}) { +/** Layout wrapper for confirmation or cancellation actions. */ +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
- {children} - {showCloseButton && ( - - - - )} -
+ /> ); } -function DialogTitle({ className, ...props }: React.ComponentProps) { +/** Accessible dialog title. */ +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( + cn( + "font-semibold text-foreground text-lg", + typeof className === "function" ? className(state) : className, + ) + } {...props} /> ); } -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { +/** Accessible dialog description. */ +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { return ( + cn( + "text-muted-foreground text-sm", + typeof className === "function" ? className(state) : className, + ) + } {...props} /> ); } +export type { + DialogContentProps, + DialogOverlayProps, + DialogPortalProps, + DialogProps, + DialogTriggerProps, +}; export { Dialog, DialogClose, diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index ad0092c..91bd45f 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -60,7 +60,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", + "group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:text-destructive-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive-foreground", className, )} {...props} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index e72e556..73fa5aa 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,19 +1,41 @@ -import type * as React from "react"; - +import { Input as InputPrimitive } from "@base-ui-components/react/input"; +import { TriangleAlert } from "lucide-react"; +import type { ComponentProps } from "react"; import { cn } from "@/lib/utils"; -function Input({ className, type, ...props }: React.ComponentProps<"input">) { +type InputProps = ComponentProps; + +/** + * Input is the single-line text-entry primitive, implemented from the Nebari + * Figma spec on top of Base UI's `Input`. Dropped inside a `Field`, it wires its + * accessible name and `aria-describedby` to `FieldLabel` / `FieldDescription` + * automatically — no manual `htmlFor` / `id`. Standalone, pair it with `Label` + * via `htmlFor` / `id`. + * + * States map to the design: `border-input` at rest, `border-border-strong` on + * hover, a `ring` focus outline, `bg-muted` + dimmed when disabled, and — when + * the field is invalid (driven by `aria-invalid` / Base UI's `data-invalid`) — a + * 2px `destructive` outline plus a trailing `triangle-alert` icon. The icon is a + * non-color cue so the invalid state stays compliant with WCAG 1.4.1 on its own. + */ +function Input({ className, ...props }: InputProps) { return ( - +
+ + +
); } +export type { InputProps }; export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx index ce80d7e..6abf61e 100644 --- a/frontend/src/components/ui/label.tsx +++ b/frontend/src/components/ui/label.tsx @@ -1,16 +1,23 @@ -"use client"; - -import { Label as LabelPrimitive } from "radix-ui"; -import type * as React from "react"; - +import type { ComponentProps } from "react"; import { cn } from "@/lib/utils"; -function Label({ className, ...props }: React.ComponentProps) { +type LabelProps = ComponentProps<"label">; + +/** + * Label is the standalone accessible label for a form control, styled from the + * Nebari Figma spec. Pair it with a control via `htmlFor` / `id` when not using + * `Field`. Inside a `Field`, prefer `FieldLabel` (built on Base UI's + * `Field.Label`), which wires the association automatically. The + * `peer-disabled:` styles dim the label when an adjacent `peer` control is + * disabled. + */ +function Label({ className, ...props }: LabelProps) { return ( - ) { - return ; -} +type SelectTriggerProps = SelectPrimitive.Trigger.Props; + +type SelectContentProps = SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >; + +/** + * Select groups the trigger, value, popup, and option items. Value, open state, + * disabled/read-only behavior, and Field integration come from Base UI. + */ +const Select = SelectPrimitive.Root; -function SelectGroup({ className, ...props }: React.ComponentProps) { +const selectTriggerClassName = + "flex h-9 w-full items-center justify-between gap-1.5 rounded-md border border-input bg-background py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs outline-none hover:border-border-strong focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:hover:border-ring data-[pressed]:border-ring data-[pressed]:ring-2 data-[pressed]:ring-ring data-[pressed]:hover:border-ring data-[popup-open]:border-ring data-[popup-open]:ring-2 data-[popup-open]:ring-ring data-[popup-open]:hover:border-ring disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive-foreground aria-invalid:ring-2 aria-invalid:ring-destructive-foreground aria-invalid:hover:border-destructive-foreground aria-invalid:focus-visible:border-destructive-foreground aria-invalid:focus-visible:ring-destructive-foreground aria-invalid:data-[pressed]:border-destructive-foreground aria-invalid:data-[pressed]:ring-destructive-foreground aria-invalid:data-[popup-open]:border-destructive-foreground aria-invalid:data-[popup-open]:ring-destructive-foreground data-placeholder:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"; + +/** Groups related options inside the popup list. */ +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { return ( ); } -function SelectValue({ ...props }: React.ComponentProps) { - return ; +/** Displays the selected value inside the trigger. */ +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ); } -function SelectTrigger({ - className, - size = "default", - children, - ...props -}: React.ComponentProps & { - size?: "sm" | "default"; -}) { +/** Combobox button that opens the popup and shows the selected value. */ +function SelectTrigger({ className, children, ...props }: SelectTriggerProps) { return ( {children} - - - + } + /> ); } +/** + * Portaled popup that positions the option list against the trigger and adds + * scroll affordances for long menus. + */ function SelectContent({ className, children, - position = "item-aligned", + side = "bottom", + sideOffset = 0, align = "center", + alignOffset = 0, + alignItemWithTrigger = false, ...props -}: React.ComponentProps) { +}: SelectContentProps) { return ( - - - - {children} - - - + + {children} + + + ); } -function SelectLabel({ className, ...props }: React.ComponentProps) { +/** Label for a `SelectGroup`, rendered inside the popup list. */ +function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { return ( - ); } -function SelectItem({ - className, - children, - ...props -}: React.ComponentProps) { +/** Selectable option row. Disabled items are announced and cannot be selected. */ +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { return ( - - - - - - {children} + + {children} + + + } + > + + ); } -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { +/** Visual separator between option groups inside the popup list. */ +function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { return ( ) { +}: React.ComponentProps) { return ( - - + ); } +/** + * Scroll affordance shown when options overflow below the visible popup area. + */ function SelectScrollDownButton({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - - + ); } diff --git a/frontend/src/components/ui/spinner.tsx b/frontend/src/components/ui/spinner.tsx new file mode 100644 index 0000000..38c625f --- /dev/null +++ b/frontend/src/components/ui/spinner.tsx @@ -0,0 +1,51 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { LoaderCircle, type LucideProps } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const spinnerVariants = cva("animate-spin", { + variants: { + // `default` intentionally adds no `size-*` class: standalone it falls back + // to lucide's own size, and inside `Button` it lets the button's + // `[&_svg:not([class*='size-'])]:size-*` rule size the spinner per button + // size. The explicit sizes are for standalone use. + size: { + xs: "size-3.5", + sm: "size-4", + default: "", + lg: "size-6", + xl: "size-8", + }, + }, + defaultVariants: { + size: "default", + }, +}); + +type SpinnerProps = Omit & + VariantProps & { + /** + * Accessible label announced by assistive tech. Defaults to `"Loading"`. + */ + label?: string; + }; + +/** + * Minimal loading spinner — an `animate-spin` wrapper around lucide's + * `LoaderCircle`. Exposes `role="status"` so assistive tech announces it and + * tests can query it. Used by `Button`'s `loading` state. + */ +function Spinner({ className, size, label = "Loading", ...props }: SpinnerProps) { + return ( + + ); +} + +export type { SpinnerProps }; +export { Spinner, spinnerVariants }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 1175d9f..03196ce 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,62 +1,85 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@import "@fontsource-variable/inter"; +@import "@fontsource-variable/geist"; +@import "@fontsource/ibm-plex-mono/400.css"; +@import "@fontsource/ibm-plex-mono/500.css"; @custom-variant dark (&:is(.dark *)); -/* Design-system tokens copied from the nebari-landing repo so the serving-pack - UI shares one source of truth for color, radius, and status styling. Keep - these in sync with nebari-landing rather than hand-tuning values here. */ +/* Nebari design-system tokens, installed from the @nebari/design shadcn + registry (`shadcn add @nebari/theme`). These are the resolved oklch values of + the Nebari brand palette for light and dark modes — treat them as + upstream-managed and re-pull via `shadcn add @nebari/theme` rather than + hand-tuning. App-specific status / pill / text-secondary tokens (which the + Nebari theme does not ship) are layered on top of each block. */ :root { - --background: #ffffff; - --foreground: #1b1b1b; + --radius: 0.625rem; - --card: #ffffff; - --card-foreground: #1b1b1b; + --background: oklch(100% 0 0); + --foreground: oklch(26.94% 0.0037 286.15); + --scrim: rgba(0, 0, 0, 0.5); - --popover: #ffffff; - --popover-foreground: #1b1b1b; + --card: oklch(100% 0 0); + --card-foreground: oklch(26.94% 0.0037 286.15); - --primary: #9b3dcc; - --primary-foreground: #ffffff; + --popover: oklch(100% 0 0); + --popover-foreground: oklch(26.94% 0.0037 286.15); - --secondary: #f3f5f6; - --secondary-foreground: #1b1b1b; + --primary: oklch(55.06% 0.1886 311.45); + --primary-foreground: oklch(100% 0 0); + --primary-hover: oklch(47.01% 0.1577 311.26); - --muted: #f3f5f6; - --muted-foreground: #65748a; + --secondary: oklch(95.04% 0.0042 236.5); + --secondary-foreground: oklch(32.95% 0.0209 254.12); - --accent: #e7eaee; - --accent-foreground: #1b1b1b; + --muted: oklch(94.94% 0.0013 286.37); + --muted-foreground: oklch(54.86% 0.0154 285.88); + --muted-foreground-strong: oklch(47.01% 0.0112 285.96); - --pill-category-fg: #475467; + --accent: oklch(95.04% 0.0042 236.5); + --accent-foreground: oklch(32.95% 0.0209 254.12); - --destructive: #d92d20; + --destructive: oklch(95.06% 0.0247 29.93); + --destructive-foreground: oklch(54.97% 0.2151 27.33); - --border: #d0d5dd; - --input: #d0d5dd; - --ring: var(--primary); + --warning: oklch(95.02% 0.0692 92.11); + --warning-foreground: oklch(46.91% 0.096 91.9); - --chart-1: #98a2b3; - --chart-2: #667085; - --chart-3: #475467; - --chart-4: #344054; - --chart-5: #1d2939; + --success: oklch(94.94% 0.0433 149.41); + --success-foreground: oklch(47.01% 0.1313 149.41); - --radius: 0.625rem; + --border: oklch(78.06% 0.0056 286.27); + --input: oklch(69.88% 0.013 286.06); + --border-strong: oklch(61.96% 0.0134 286); + --ring: oklch(61.98% 0.2159 311.67); - --text-secondary: #4e596a; + --chart-1: oklch(61.98% 0.2159 311.67); + --chart-2: oklch(61.96% 0.0932 187.62); + --chart-3: oklch(70.05% 0.1417 86.43); + --chart-4: oklch(62.1% 0.188 264.3); + --chart-5: oklch(61.95% 0.1718 149.7); + + --sidebar: oklch(97.91% 0 0); + --sidebar-foreground: oklch(26.94% 0.0037 286.15); + --sidebar-primary: oklch(55.06% 0.1886 311.45); + --sidebar-primary-foreground: oklch(100% 0 0); + --sidebar-accent: oklch(95.04% 0.0042 236.5); + --sidebar-accent-foreground: oklch(32.95% 0.0209 254.12); + --sidebar-border: oklch(78.06% 0.0056 286.27); + --sidebar-ring: oklch(61.98% 0.2159 311.67); + + /* Motion tokens */ + --duration-fast: 100ms; + --duration-base: 200ms; + --duration-slow: 350ms; + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-emphasized: cubic-bezier(0.2, 0, 0, 1); - --sidebar: #ffffff; - --sidebar-foreground: #1b1b1b; - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: var(--primary-foreground); - --sidebar-accent: #f3f5f6; - --sidebar-accent-foreground: #1b1b1b; - --sidebar-border: #d0d5dd; - --sidebar-ring: var(--primary); + /* App-specific tokens (not part of the Nebari theme) */ + --pill-category-fg: #475467; + --text-secondary: #4e596a; --status-healthy-bg: #10b9811a; --status-healthy-fg: #047857; @@ -72,53 +95,63 @@ } .dark { - --background: #111418; - --foreground: #f3f5f6; + --background: oklch(26.94% 0.0037 286.15); + --foreground: oklch(97.91% 0 0); + --scrim: rgba(0, 0, 0, 0.6); - --card: #111418; - --card-foreground: #f3f5f6; + --card: oklch(33.01% 0.0052 286.11); + --card-foreground: oklch(97.91% 0 0); - --popover: #111418; - --popover-foreground: #f3f5f6; + --popover: oklch(26.94% 0.0037 286.15); + --popover-foreground: oklch(97.91% 0 0); - --primary: #9b3dcc; - --primary-foreground: #ffffff; + --primary: oklch(61.98% 0.2159 311.67); + --primary-foreground: oklch(0% 0 0); + --primary-hover: oklch(69.98% 0.1926 311.48); - --secondary: #22282f; - --secondary-foreground: #f3f5f6; + --secondary: oklch(40% 0.0269 250.57); + --secondary-foreground: oklch(95.04% 0.0042 236.5); - --muted: #22282f; - /* Lightened from #65748a to meet WCAG AA (4.5:1) for secondary text in dark - mode, including the theme toggle labels rendered on the muted background. */ - --muted-foreground: #909db0; + --muted: oklch(33.01% 0.0052 286.11); + --muted-foreground: oklch(69.88% 0.013 286.06); + --muted-foreground-strong: oklch(78.06% 0.0056 286.27); - --accent: #3d4553; - --accent-foreground: #f3f5f6; + --accent: oklch(40% 0.0269 250.57); + --accent-foreground: oklch(95.04% 0.0042 236.5); - --pill-category-fg: #ffffff; + --destructive: oklch(33.1% 0.1332 27.42); + --destructive-foreground: oklch(78.02% 0.1024 27.78); - --destructive: #f04438; + --warning: oklch(33.09% 0.0677 92.2); + --warning-foreground: oklch(87.09% 0.1248 91.93); - --border: #3d4553; - --input: #3d4553; - --ring: var(--primary); + --success: oklch(32.88% 0.0887 149.73); + --success-foreground: oklch(87.04% 0.0814 149.55); - --chart-1: #b7c0cc; - --chart-2: #98a2b3; - --chart-3: #667085; - --chart-4: #475467; - --chart-5: #344054; + --border: oklch(47.01% 0.0112 285.96); + --input: oklch(54.86% 0.0154 285.88); + --border-strong: oklch(61.96% 0.0134 286); + --ring: oklch(69.98% 0.1926 311.48); + --chart-1: oklch(69.98% 0.1926 311.48); + --chart-2: oklch(69.9% 0.0995 187.56); + --chart-3: oklch(77.95% 0.1259 85.96); + --chart-4: oklch(69.95% 0.1433 264.44); + --chart-5: oklch(69.96% 0.1814 149.67); + + --sidebar: oklch(26.94% 0.0037 286.15); + --sidebar-foreground: oklch(97.91% 0 0); + --sidebar-primary: oklch(61.98% 0.2159 311.67); + --sidebar-primary-foreground: oklch(0% 0 0); + --sidebar-accent: oklch(40% 0.0269 250.57); + --sidebar-accent-foreground: oklch(95.04% 0.0042 236.5); + --sidebar-border: oklch(47.01% 0.0112 285.96); + --sidebar-ring: oklch(69.98% 0.1926 311.48); + + /* App-specific tokens (not part of the Nebari theme) */ + --pill-category-fg: #ffffff; --text-secondary: #d0d5dd; - --sidebar: #111418; - --sidebar-foreground: #f3f5f6; - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: var(--primary-foreground); - --sidebar-accent: #22282f; - --sidebar-accent-foreground: #f3f5f6; - --sidebar-border: #3d4553; - --sidebar-ring: var(--primary); --status-healthy-bg: #10b9811a; --status-healthy-fg: #6ee7b7; --status-healthy-dot: #34d399; @@ -132,57 +165,112 @@ --status-default-dot: #f3f5f6; } +@keyframes nebari-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes nebari-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes nebari-slide-up-fade { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes nebari-slide-down-fade { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(6px); + } +} + @theme inline { - --font-sans: "Inter Variable", sans-serif; + --font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-scrim: var(--scrim); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-hover: var(--primary-hover); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); - --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-muted-foreground-strong: var(--muted-foreground-strong); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); - --color-foreground: var(--foreground); - --color-background: var(--background); + --color-border: var(--border); + --color-input: var(--input); + --color-border-strong: var(--border-strong); + --color-ring: var(--ring); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --radius-xl: calc(var(--radius) + 4px); + + --animate-fade-in: nebari-fade-in var(--duration-base) var(--ease-standard) both; + --animate-fade-out: nebari-fade-out var(--duration-base) var(--ease-standard) both; + --animate-slide-up-fade: nebari-slide-up-fade var(--duration-base) var(--ease-emphasized) both; + --animate-slide-down-fade: nebari-slide-down-fade var(--duration-base) var(--ease-emphasized) both; } @layer base { From 34a6525c635cdeca898c1f572a3eb7d8bfa3a6d6 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Tue, 7 Jul 2026 12:18:58 -0400 Subject: [PATCH 10/15] Migrate ui from static go served to standalone. --- .github/workflows/build-images.yaml | 36 ++ .github/workflows/lint.yaml | 20 + .github/workflows/test.yaml | 20 + README.md | 15 +- .../templates/frontend-configmap.yaml | 100 ++++ .../templates/frontend-deployment.yaml | 80 +++ .../templates/frontend-service.yaml | 18 + .../templates/key-manager-deployment.yaml | 15 +- .../templates/key-manager-nebariapp.yaml | 33 +- charts/nebari-llm-serving/values.yaml | 71 ++- dev/Makefile | 12 +- dev/manifests/key-manager.yaml | 9 +- dev/test-auth.sh | 131 +++++ dev/uidev/go.mod | 3 - dev/uidev/main.go | 162 ------ docs/src/content/docs/architecture.md | 37 +- docs/src/content/docs/cicd-and-releasing.md | 24 +- docs/src/content/docs/configuration.md | 33 +- docs/src/content/docs/installation.md | 97 ++-- docs/src/content/docs/local-development.md | 41 +- docs/src/content/docs/troubleshooting.md | 4 +- docs/src/content/docs/ui-development.md | 90 +-- frontend-rewrite-plan.md | 86 +-- frontend/.dockerignore | 5 + frontend/Dockerfile | 33 ++ frontend/nginx.default.conf | 63 +++ key-manager/cmd/main.go | 33 +- key-manager/go.mod | 28 +- key-manager/go.sum | 38 +- key-manager/internal/api/jwt_validator.go | 333 +++++++++++ key-manager/internal/api/middleware.go | 109 ++-- key-manager/internal/api/middleware_test.go | 346 ++++++------ key-manager/internal/ui/embed.go | 6 - key-manager/internal/ui/static/app.js | 422 -------------- key-manager/internal/ui/static/favicon.svg | 11 - key-manager/internal/ui/static/index.html | 177 ------ .../internal/ui/static/nebari-logo.svg | 24 - key-manager/internal/ui/static/style.css | 530 ------------------ 38 files changed, 1518 insertions(+), 1777 deletions(-) create mode 100644 charts/nebari-llm-serving/templates/frontend-configmap.yaml create mode 100644 charts/nebari-llm-serving/templates/frontend-deployment.yaml create mode 100644 charts/nebari-llm-serving/templates/frontend-service.yaml create mode 100755 dev/test-auth.sh delete mode 100644 dev/uidev/go.mod delete mode 100644 dev/uidev/main.go create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.default.conf create mode 100644 key-manager/internal/api/jwt_validator.go delete mode 100644 key-manager/internal/ui/embed.go delete mode 100644 key-manager/internal/ui/static/app.js delete mode 100644 key-manager/internal/ui/static/favicon.svg delete mode 100644 key-manager/internal/ui/static/index.html delete mode 100644 key-manager/internal/ui/static/nebari-logo.svg delete mode 100644 key-manager/internal/ui/static/style.css diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 87b89d4..50ad70c 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -118,3 +118,39 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + build-frontend: + name: Build and push frontend image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_PREFIX }}/frontend + tags: | + type=sha + type=ref,event=branch + type=semver,pattern=v{{version}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push frontend image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: frontend/ + file: frontend/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 67928ca..3b50ab3 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -41,6 +41,26 @@ jobs: version: v2.4.0 working-directory: key-manager + lint-frontend: + name: Lint frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: frontend/.node-version + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install deps + working-directory: frontend + run: npm ci + + - name: Biome check + working-directory: frontend + run: npm run check + lint-helm: name: Lint Helm chart runs-on: ubuntu-latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 230b57f..2845513 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,3 +36,23 @@ jobs: - name: Run tests working-directory: key-manager run: go test ./... + + test-frontend: + name: Test frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: frontend/.node-version + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install deps + working-directory: frontend + run: npm ci + + - name: Run tests + working-directory: frontend + run: npm test diff --git a/README.md b/README.md index d2eaec1..78481e8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ You apply an `LLMModel` custom resource and the operator handles the rest: model Each model gets per-model access control via OIDC groups (works with any OIDC provider, tested against Keycloak). Two auth endpoints are created per model: external access via API keys, and internal (in-cluster) access via JWT. Both paths go through Envoy AI Gateway for token counting and rate limiting. -An optional key manager web UI lets users generate and revoke API keys for models they have access to. +An optional key manager lets users generate and revoke API keys for models they have access to: a React single-page app (served by its own nginx image) backed by an API-only Go service, with SPA-managed Keycloak login. Models can be loaded from HuggingFace (default) or mounted as OCI/modelcar images. Model downloads use a purpose-built [distroless container image](model-downloader/) with pixi-managed dependencies for reproducibility. @@ -205,7 +205,9 @@ response = client.chat.completions.create( | `defaults.epp.image` | Endpoint Picker (llm-d-inference-scheduler) image | `ghcr.io/llm-d/llm-d-inference-scheduler:v0.8.0` | | `defaults.storage.storageClassName` | Default StorageClass for model PVCs (empty = cluster default) | `""` | | `defaults.monitoring.enabled` | Enable PodMonitor for Prometheus scraping | `true` | -| `keyManager.enabled` | Deploy the key manager web UI | `true` | +| `keyManager.enabled` | Deploy the key manager API service | `true` | +| `frontend.enabled` | Deploy the React UI (nginx) frontend | `true` | +| `frontend.keycloak.url` | External Keycloak URL for SPA login (required when `frontend.enabled=true`) | `""` | ## Architecture @@ -223,7 +225,9 @@ Admin applies LLMModel CR | Key Manager (optional) | - +---> Web UI behind NebariApp (Keycloak/OIDC login) + +---> React SPA (nginx image) behind NebariApp; SPA-managed Keycloak PKCE login + +---> nginx serves the SPA + /config.json and proxies /api to the key-manager + +---> key-manager is API-only; validates the Keycloak bearer against JWKS in-process +---> Generates API keys, writes to K8s Secrets +---> Envoy Gateway validates keys natively ``` @@ -233,7 +237,8 @@ Admin applies LLMModel CR | Image | Description | |-------|-------------| | `ghcr.io/nebari-dev/nebari-llm-serving-pack/operator` | LLM operator - reconciles LLMModel CRDs | -| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager web UI and API | +| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager REST API | +| `ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend` | LLM serving pack React UI (nginx) | | `ghcr.io/nebari-dev/nebari-llm-serving-pack/model-downloader` | Model download init container (distroless, pixi-managed) | ### Infrastructure requirements @@ -276,7 +281,7 @@ make teardown ### Key manager UI -The key manager web UI is a [React](https://react.dev) + TypeScript app (Vite, Tailwind, shadcn/ui) in [`frontend/`](frontend/). For a one-command dev loop that needs **no Keycloak**: +The key manager web UI is a [React](https://react.dev) + TypeScript app (Vite, Tailwind, shadcn/ui) in [`frontend/`](frontend/). In production it ships as its own nginx image (`ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend`) that serves the SPA and proxies `/api` to the API-only key-manager; it is not embedded in the Go binary. For a one-command dev loop that needs **no Keycloak**: ```bash # One-time: copy dev/.env.example to dev/.env and set OPENROUTER_API_KEY diff --git a/charts/nebari-llm-serving/templates/frontend-configmap.yaml b/charts/nebari-llm-serving/templates/frontend-configmap.yaml new file mode 100644 index 0000000..bb00c5d --- /dev/null +++ b/charts/nebari-llm-serving/templates/frontend-configmap.yaml @@ -0,0 +1,100 @@ +{{- if and .Values.frontend.enabled .Values.keyManager.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "nebari-llm-serving.fullname" . }}-frontend-config + namespace: {{ include "nebari-llm-serving.operatorNamespace" . }} + labels: + {{- include "nebari-llm-serving.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +data: + # Served by nginx at /config.json. The SPA fetches this at startup so Keycloak + # settings change without rebuilding the image. clientId defaults to the + # nebari-operator SPA-client convention (--spa); the + # NebariApp for the UI is named "-key-manager", so the SPA client is + # "--key-manager-spa". + config.json: | + { + "keycloak": { + "url": {{ required "frontend.keycloak.url is required when frontend.enabled=true" .Values.frontend.keycloak.url | quote }}, + "realm": {{ .Values.frontend.keycloak.realm | quote }}, + "clientId": {{ default (printf "%s-%s-key-manager-spa" (include "nebari-llm-serving.operatorNamespace" .) (include "nebari-llm-serving.fullname" .)) .Values.frontend.keycloak.clientId | quote }} + }{{ with .Values.frontend.title }}, + "title": {{ . | quote }}{{ end }} + } + + # nginx configuration for the frontend pod. + # + # Traffic flow (production): + # Browser → Gateway → frontend Service (nginx:{{ .Values.frontend.port }}) + # ├─ /api/* → proxy_pass to the key-manager ClusterIP (cluster DNS, never + # │ public); nginx forwards the Authorization: Bearer header as-is + # │ and the key-manager validates it against Keycloak JWKS. + # └─ /* → serve the SPA; keycloak-js handles OIDC PKCE in the browser. + nginx.conf: | + worker_processes auto; + pid /tmp/nginx.pid; + + events { + worker_connections 1024; + } + + http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp_path; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + sendfile on; + keepalive_timeout 65; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + server { + listen {{ .Values.frontend.port }}; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml application/xml+rss text/javascript + image/svg+xml; + gzip_min_length 1024; + + # Proxy /api/* to the key-manager ClusterIP — never exposed publicly. + location /api/ { + proxy_pass http://{{ include "nebari-llm-serving.fullname" . }}-key-manager.{{ include "nebari-llm-serving.operatorNamespace" . }}.svc.cluster.local:8080/api/; + proxy_set_header Authorization $http_authorization; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_http_version 1.1; + } + + # Cache content-hashed static assets aggressively. + location ~* \.(js|css|png|svg|ico|woff2?|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # SPA fallback. + location / { + try_files $uri $uri/ /index.html; + } + } + } +{{- end }} diff --git a/charts/nebari-llm-serving/templates/frontend-deployment.yaml b/charts/nebari-llm-serving/templates/frontend-deployment.yaml new file mode 100644 index 0000000..6916847 --- /dev/null +++ b/charts/nebari-llm-serving/templates/frontend-deployment.yaml @@ -0,0 +1,80 @@ +{{- if and .Values.frontend.enabled .Values.keyManager.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nebari-llm-serving.fullname" . }}-frontend + namespace: {{ include "nebari-llm-serving.operatorNamespace" . }} + labels: + {{- include "nebari-llm-serving.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: {{ include "nebari-llm-serving.fullname" . }}-frontend + template: + metadata: + labels: + app: {{ include "nebari-llm-serving.fullname" . }}-frontend + {{- include "nebari-llm-serving.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + securityContext: + runAsNonRoot: true + runAsUser: 101 # nginx default non-root uid + seccompProfile: + type: RuntimeDefault + containers: + # nginx serves the React SPA and proxies /api/* to the key-manager + # ClusterIP. This is the service the NebariApp targets; auth is handled + # client-side by keycloak-js (PKCE/S256) and validated by the key-manager. + - name: frontend + image: {{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }} + imagePullPolicy: {{ .Values.frontend.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: http + containerPort: {{ .Values.frontend.port }} + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 25m + memory: 32Mi + volumeMounts: + # nginx needs writable /tmp, /var/cache/nginx, and /var/run. + - name: nginx-tmp + mountPath: /tmp + - name: nginx-cache + mountPath: /var/cache/nginx + - name: nginx-run + mountPath: /var/run + # Helm-rendered nginx.conf (adds the /api/ proxy to the key-manager). + - name: frontend-config + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + readOnly: true + # Helm-rendered config.json, overriding the placeholder baked into + # the image at build time. + - name: frontend-config + mountPath: /usr/share/nginx/html/config.json + subPath: config.json + readOnly: true + volumes: + - name: nginx-tmp + emptyDir: {} + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} + - name: frontend-config + configMap: + name: {{ include "nebari-llm-serving.fullname" . }}-frontend-config + terminationGracePeriodSeconds: 10 +{{- end }} diff --git a/charts/nebari-llm-serving/templates/frontend-service.yaml b/charts/nebari-llm-serving/templates/frontend-service.yaml new file mode 100644 index 0000000..a36df09 --- /dev/null +++ b/charts/nebari-llm-serving/templates/frontend-service.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.frontend.enabled .Values.keyManager.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nebari-llm-serving.fullname" . }}-frontend + namespace: {{ include "nebari-llm-serving.operatorNamespace" . }} + labels: + {{- include "nebari-llm-serving.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app: {{ include "nebari-llm-serving.fullname" . }}-frontend +{{- end }} diff --git a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml index 645953e..47f46eb 100644 --- a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml @@ -36,10 +36,21 @@ spec: fieldPath: metadata.namespace - name: LLM_OIDC_GROUPS_CLAIM value: {{ .Values.auth.oidc.groupsClaim | quote }} - - name: LLM_AUTH_COOKIE_PREFIX - value: {{ .Values.auth.oidc.cookiePrefix | quote }} - name: LLM_LISTEN_ADDR value: ":8080" + {{- if not .Values.keyManager.devMode.enabled }} + # Model B: the key-manager validates the SPA's bearer token against + # Keycloak's JWKS. LLM_KEYCLOAK_URL is the URL used to fetch JWKS + # (defaults to the SPA's external Keycloak URL; override with an + # in-cluster URL to skip the round-trip through the gateway). + # LLM_KEYCLOAK_ISSUER_URL is the URL embedded in tokens as `iss`. + - name: LLM_KEYCLOAK_URL + value: {{ .Values.keyManager.keycloak.url | default .Values.frontend.keycloak.url | quote }} + - name: LLM_KEYCLOAK_REALM + value: {{ .Values.keyManager.keycloak.realm | default .Values.frontend.keycloak.realm | quote }} + - name: LLM_KEYCLOAK_ISSUER_URL + value: {{ .Values.keyManager.keycloak.issuerURL | default .Values.frontend.keycloak.url | quote }} + {{- end }} {{- if .Values.keyManager.auditInterval }} - name: LLM_AUDIT_INTERVAL value: {{ .Values.keyManager.auditInterval | quote }} diff --git a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml index 50f82e7..af8c8b3 100644 --- a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml @@ -9,9 +9,12 @@ metadata: spec: hostname: {{ required "keyManager.nebariApp.hostname is required when keyManager.nebariApp.enabled=true" .Values.keyManager.nebariApp.hostname | quote }} gateway: {{ .Values.keyManager.nebariApp.gateway | quote }} + # The NebariApp targets the frontend nginx Service, which serves the React SPA + # and reverse-proxies /api/* to the key-manager over cluster DNS. The + # key-manager Service is never exposed through the gateway. service: - name: {{ include "nebari-llm-serving.fullname" . }}-key-manager - port: 8080 + name: {{ include "nebari-llm-serving.fullname" . }}-frontend + port: {{ .Values.frontend.service.port }} {{- with .Values.keyManager.nebariApp.routing }} # routing is passed through verbatim from values so consumers can set # routes / publicRoutes / tls / annotations without further chart edits. @@ -21,12 +24,36 @@ spec: routing: {{- toYaml . | nindent 4 }} {{- end }} + # Model B (SPA-managed Keycloak): auth is performed client-side by keycloak-js + # (PKCE/S256) in the browser and validated in-process by the key-manager + # against Keycloak's JWKS. enforceAtGateway stays false, so the operator + # provisions the public SPA client but emits no gateway SecurityPolicy. auth: enabled: true provider: keycloak - provisionClient: {{ .Values.keyManager.nebariApp.auth.provisionClient }} + enforceAtGateway: false scopes: {{- toYaml .Values.keyManager.nebariApp.auth.scopes | nindent 6 }} + # The groups mapper ensures access tokens carry group claims so the + # key-manager can filter models by group membership. + keycloakConfig: + protocolMappers: + - name: group-membership + protocolMapper: oidc-group-membership-mapper + config: + claim.name: groups + full.path: "false" + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + # Public Keycloak client for the React SPA (PKCE, no secret). Client ID + # defaults to --spa; the operator writes it to the + # -oidc-spa-client ConfigMap. + spaClient: + enabled: true + {{- with .Values.keyManager.nebariApp.auth.spaClientId }} + clientId: {{ . | quote }} + {{- end }} {{- with .Values.keyManager.nebariApp.landingPage }} landingPage: enabled: {{ .enabled }} diff --git a/charts/nebari-llm-serving/values.yaml b/charts/nebari-llm-serving/values.yaml index 54890f1..1ddb059 100644 --- a/charts/nebari-llm-serving/values.yaml +++ b/charts/nebari-llm-serving/values.yaml @@ -54,7 +54,6 @@ auth: existingSecret: "" groupsClaim: groups audience: "" - cookiePrefix: IdToken envoyAIGateway: # install: if true, installs the Envoy AI Gateway (not yet implemented) @@ -81,6 +80,19 @@ keyManager: pullPolicy: Always auditInterval: 5m oidcUserinfoURL: "" + # keycloak - how the key-manager validates the SPA's bearer token (Model B). + # All three default to frontend.keycloak.* when left empty, so a typical + # install only sets frontend.keycloak.{url,realm}. Override here only to point + # JWKS fetching at an in-cluster Keycloak URL (faster, avoids the gateway + # round-trip) while still validating the external issuer. + keycloak: + # url - base URL used to fetch JWKS (/realms//protocol/openid-connect/certs). + url: "" + # realm - Keycloak realm the tokens are issued from. + realm: "" + # issuerURL - the URL embedded in tokens as `iss`; validated exactly. + # Defaults to frontend.keycloak.url (the URL the browser talks to). + issuerURL: "" # nebariApp - the NebariApp CR that exposes the key-manager UI via the # nebari-operator (HTTPRoute, TLS cert, Keycloak client, landing-page tile). # Set enabled=false when installing standalone without a Nebari cluster. @@ -104,16 +116,19 @@ keyManager: - pathPrefix: / pathType: PathPrefix auth: - # provisionClient: true tells nebari-operator to create a dedicated - # Keycloak client for the UI and write credentials to the Secret - # -oidc-client. The gateway uses these to enforce - # OIDC login before forwarding to the key-manager. - provisionClient: true + # Model B (SPA-managed Keycloak). The operator provisions a public PKCE + # SPA client and (because enforceAtGateway is false in the template) emits + # no gateway SecurityPolicy — keycloak-js logs in from the browser and the + # key-manager validates the bearer token against Keycloak's JWKS. scopes: - openid - profile - email - groups + # spaClientId - override the SPA client ID. Leave empty to use the operator + # convention --spa. Must match frontend config.json + # (frontend.keycloak.clientId), which defaults to the same convention. + spaClientId: "" # landingPage controls how the UI appears on the Nebari landing page. landingPage: enabled: true @@ -124,18 +139,50 @@ keyManager: # keycloak, argocd, kubernetes) or a URL to a custom image. icon: "keycloak" priority: 100 - # healthCheck - lets the Nebari landing page actively probe the - # key-manager so its tile shows Healthy/Unhealthy instead of "Unknown". - # The landing webapi probes http://.:; - # the key-manager serves its SPA at "/" (HTTP 200, unauthenticated) while - # /api/* is auth-gated, so "/" is the correct probe path. port defaults to - # the service port (8080) when omitted. + # healthCheck - lets the Nebari landing page actively probe the UI so its + # tile shows Healthy/Unhealthy instead of "Unknown". The landing webapi + # probes http://.:; the NebariApp now + # targets the frontend nginx Service, which serves the SPA at "/" (HTTP + # 200, unauthenticated), so "/" is the correct probe path. port defaults + # to the service port (frontend.service.port) when omitted. healthCheck: enabled: true path: / intervalSeconds: 30 timeoutSeconds: 5 +# frontend - the React SPA served by its own nginx image. nginx serves the +# static assets, renders /config.json (Keycloak settings) from the values below, +# and reverse-proxies /api/* to the key-manager Service. This is the Service the +# NebariApp targets; the key-manager Service is never exposed publicly. +frontend: + # Set enabled=false to skip the frontend (e.g. an API-only test install). + enabled: true + image: + repository: ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend + # tag defaults to .Chart.AppVersion when empty so the chart and image + # versions move together. Override to pull a specific build. + tag: "" + pullPolicy: Always + # port nginx listens on. Must be > 1024 — the container runs as non-root + # (UID 101) with all capabilities dropped and cannot bind privileged ports. + port: 8080 + service: + port: 8080 + # title - optional browser-tab title override rendered into config.json. + title: "" + # keycloak - connection settings rendered into /config.json at deploy time and + # fetched by the SPA at startup, so the image needs no build-time config. + keycloak: + # url - full external Keycloak base URL the browser talks to (Keycloak 17+ + # does not use /auth as a context root). Required when frontend.enabled=true. + # e.g. https://keycloak.your-cluster.example.com + url: "" + realm: nebari + # clientId - the public PKCE SPA client ID. Leave empty to use the operator + # convention --spa (matches the provisioned client). + clientId: "" + operator: image: repository: ghcr.io/nebari-dev/nebari-llm-serving-pack/operator diff --git a/dev/Makefile b/dev/Makefile index aa17d53..37bea2b 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -24,7 +24,7 @@ AI_GATEWAY_VERSION ?= v0.5.0 HELM_MAJOR := $(shell helm version --template '{{.Version}}' 2>/dev/null | sed -E 's/^v?([0-9]+).*/\1/') HELM_FORCE_CONFLICTS := $(if $(filter-out 1 2 3,$(HELM_MAJOR)),--force-conflicts,) -.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager deploy-keycloak apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui logs-operator logs-key-manager logs-keycloak pf-key-manager pf-keycloak clean help +.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager deploy-keycloak apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui test-auth logs-operator logs-key-manager logs-keycloak pf-key-manager pf-keycloak clean help help: ## Show this help @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @@ -105,9 +105,13 @@ create-openrouter-secret: ## Create the OpenRouter provider credential (OPENROUT apply-passthrough-model: ## Apply the OpenRouter PassthroughModel (run create-openrouter-secret first) $(KUBECTL) apply -f manifests/passthrough-test-model.yaml -ui: ## Port-forward the key-manager UI to http://localhost:8080 (dev mode, no login) - @echo "key-manager UI at http://localhost:8080 (dev mode injects user 'dev')" - $(KUBECTL) -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 +ui: ## Run the React UI dev server against the local cluster (alias for run-dev) + @echo "The UI is now the React app in ../frontend, served by Vite at http://localhost:5173." + @echo "Starting the one-command dev environment (cluster + models + API port-forward + Vite)..." + ./run-dev.sh + +test-auth: ## Verify the key-manager's real bearer-token JWKS validation (deploys Keycloak, flips off dev mode, mints a token, asserts 200/401). KEEP_AUTH=1 leaves real-auth mode on. + ./test-auth.sh logs-operator: ## Tail operator logs $(KUBECTL) -n llm-operator-system logs -f deployment/llm-operator diff --git a/dev/manifests/key-manager.yaml b/dev/manifests/key-manager.yaml index 2852fb3..1e5a628 100644 --- a/dev/manifests/key-manager.yaml +++ b/dev/manifests/key-manager.yaml @@ -87,14 +87,13 @@ spec: fieldPath: metadata.namespace - name: LLM_OIDC_GROUPS_CLAIM value: "groups" - - name: LLM_AUTH_COOKIE_PREFIX - value: "IdToken" - name: LLM_LISTEN_ADDR value: ":8080" # Dev mode: there is no Keycloak or gateway OIDC layer on the kind - # cluster, so bypass auth and inject a fixed identity. The UI and - # /api/* then work behind a plain `make ui` port-forward. Never set - # this in a real deployment. See nebari-dev/llm-serving-pack#114. + # cluster, so bypass bearer-token validation and inject a fixed + # identity. The Vite dev server (VITE_DEV_NO_AUTH=true) proxies + # /api/* here. Never set this in a real deployment. See + # nebari-dev/llm-serving-pack#114. - name: LLM_DEV_MODE value: "true" - name: LLM_DEV_USER diff --git a/dev/test-auth.sh b/dev/test-auth.sh new file mode 100755 index 0000000..9506c73 --- /dev/null +++ b/dev/test-auth.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Exercise the key-manager's real bearer-token → Keycloak JWKS validation path +# on the local kind cluster (Model B). The everyday `run-dev.sh` loop bypasses +# auth (LLM_DEV_MODE + VITE_DEV_NO_AUTH), so this is the only local check that +# actually runs internal/api/jwt_validator.go end-to-end. +# +# It: deploys the dev Keycloak, flips the running key-manager out of dev mode +# into real bearer validation (JWKS fetched in-cluster, issuer = the +# port-forwarded Keycloak URL the token is minted from), mints a token via the +# public SPA client's direct-access grant, and asserts: +# - valid token → 200 +# - no Authorization header → 401 +# - garbage token → 401 +# +# By default it reverts the key-manager to dev mode on exit so the normal +# run-dev loop keeps working. Set KEEP_AUTH=1 to leave it in real-auth mode +# (e.g. to then test the SPA login flow with `cd ../frontend && npm run dev`). +set -euo pipefail + +cd "$(dirname "$0")" + +CLUSTER_NAME="${CLUSTER_NAME:-llm-serving-test}" +NS=llm-operator-system +KM_PORT="${KM_PORT:-8080}" +KC_PORT="${KC_PORT:-8180}" +REALM=nebari +CLIENT_ID=nebari-frontend-spa +KC_USER="${KC_USER:-dev}" +KC_PASS="${KC_PASS:-password}" + +# Pin kubectl to the kind cluster's context so this can never act on whatever +# the current context happens to be (e.g. a remote/production cluster). +KUBECTL="kubectl --context kind-${CLUSTER_NAME}" + +# --- preflight ------------------------------------------------------------- +if ! $KUBECTL -n "$NS" get deploy/llm-key-manager >/dev/null 2>&1; then + echo "ERROR: llm-key-manager is not deployed. Run 'make deploy' (or './run-dev.sh') first." >&2 + exit 1 +fi + +echo "==> ensuring dev Keycloak is deployed..." +if ! $KUBECTL -n keycloak get deploy/keycloak >/dev/null 2>&1; then + make deploy-keycloak +else + $KUBECTL -n keycloak rollout status deploy/keycloak --timeout=180s +fi + +# --- flip key-manager into real-auth mode ---------------------------------- +# JWKS is fetched over in-cluster DNS; the issuer must match the `iss` in tokens +# minted via the port-forward below (http://localhost:$KC_PORT). +echo "==> switching key-manager to real bearer-token validation..." +$KUBECTL -n "$NS" set env deploy/llm-key-manager \ + LLM_DEV_MODE- \ + LLM_KEYCLOAK_URL=http://keycloak.keycloak.svc.cluster.local:8080 \ + LLM_KEYCLOAK_REALM="$REALM" \ + LLM_KEYCLOAK_ISSUER_URL="http://localhost:${KC_PORT}" +$KUBECTL -n "$NS" rollout status deploy/llm-key-manager --timeout=90s + +# --- cleanup: kill port-forwards, revert to dev mode unless KEEP_AUTH ------- +KM_PF_PID=""; KC_PF_PID=""; PF_LOG="" +cleanup() { + [[ -n "$KM_PF_PID" ]] && kill "$KM_PF_PID" 2>/dev/null || true + [[ -n "$KC_PF_PID" ]] && kill "$KC_PF_PID" 2>/dev/null || true + [[ -n "$PF_LOG" ]] && rm -f "$PF_LOG" 2>/dev/null || true + if [[ "${KEEP_AUTH:-0}" != "1" ]]; then + echo "==> reverting key-manager to dev mode..." + $KUBECTL -n "$NS" set env deploy/llm-key-manager LLM_DEV_MODE=true >/dev/null + else + echo "==> KEEP_AUTH=1: leaving key-manager in real-auth mode." + fi +} +trap cleanup EXIT INT TERM + +# --- port-forwards --------------------------------------------------------- +PF_LOG="$(mktemp)" +echo "==> port-forwarding key-manager (:${KM_PORT}) and Keycloak (:${KC_PORT})..." +$KUBECTL -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >"$PF_LOG" 2>&1 & +KM_PF_PID=$! +$KUBECTL -n keycloak port-forward svc/keycloak "${KC_PORT}:8080" >>"$PF_LOG" 2>&1 & +KC_PF_PID=$! + +# Wait for both forwards to report "Forwarding from" (two lines). +for _ in $(seq 1 40); do + [[ "$(grep -c "Forwarding from" "$PF_LOG" 2>/dev/null || echo 0)" -ge 2 ]] && break + sleep 0.5 +done + +# --- mint a token via the public SPA client's direct-access grant ---------- +echo "==> minting an access token for '${KC_USER}'..." +TOKEN="" +for _ in $(seq 1 20); do + RESP="$(curl -s \ + -d "client_id=${CLIENT_ID}" -d "username=${KC_USER}" -d "password=${KC_PASS}" \ + -d grant_type=password \ + "http://localhost:${KC_PORT}/realms/${REALM}/protocol/openid-connect/token" || true)" + TOKEN="$(printf '%s' "$RESP" | grep -o '"access_token":"[^"]*"' | sed 's/"access_token":"//;s/"$//' || true)" + [[ -n "$TOKEN" ]] && break + sleep 1 +done +if [[ -z "$TOKEN" ]]; then + echo "ERROR: could not obtain an access token from Keycloak. Last response:" >&2 + printf '%s\n' "$RESP" >&2 + exit 1 +fi + +# --- assertions ------------------------------------------------------------ +FAILED=0 +code() { # $1 = curl args...; echoes the HTTP status + curl -s -o /dev/null -w '%{http_code}' "$@" +} +check() { # name expected actual + if [[ "$2" == "$3" ]]; then + echo " PASS: $1 (HTTP $3)" + else + echo " FAIL: $1 — expected $2, got $3" + FAILED=1 + fi +} + +echo "==> running assertions against http://localhost:${KM_PORT}/api/models" +check "valid token accepted" 200 "$(code -H "Authorization: Bearer ${TOKEN}" "http://localhost:${KM_PORT}/api/models")" +check "missing token rejected" 401 "$(code "http://localhost:${KM_PORT}/api/models")" +check "garbage token rejected" 401 "$(code -H "Authorization: Bearer not.a.jwt" "http://localhost:${KM_PORT}/api/models")" + +echo +if [[ "$FAILED" -eq 0 ]]; then + echo "==> PASS: bearer-token JWKS validation is working." +else + echo "==> FAIL: see assertions above." >&2 +fi +exit "$FAILED" diff --git a/dev/uidev/go.mod b/dev/uidev/go.mod deleted file mode 100644 index ad077ce..0000000 --- a/dev/uidev/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module uidev - -go 1.25 diff --git a/dev/uidev/main.go b/dev/uidev/main.go deleted file mode 100644 index 8ab9ce1..0000000 --- a/dev/uidev/main.go +++ /dev/null @@ -1,162 +0,0 @@ -// Command uidev is a zero-dependency dev server for the key-manager UI. -// -// It serves the UI's static files from disk (so edits show up immediately, -// unlike the embedded copy baked into the key-manager image), proxies /api/* -// to the running key-manager (normally a kubectl port-forward on :8080), and -// live-reloads the browser when any static file changes. Frontend devs edit -// key-manager/internal/ui/static/* and just refresh-free reload. -// -// Run via `make run-dev`, or directly: -// -// go run . -static ../../key-manager/internal/ui/static -api http://localhost:8080 -addr :5173 -package main - -import ( - "flag" - "fmt" - "io/fs" - "log" - "net/http" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "strings" - "time" -) - -func main() { - staticDir := flag.String("static", "../../key-manager/internal/ui/static", "path to the UI static files") - apiURL := flag.String("api", "http://localhost:8080", "key-manager API base URL to proxy /api/* to") - addr := flag.String("addr", ":5173", "address to listen on") - flag.Parse() - - absStatic, err := filepath.Abs(*staticDir) - if err != nil || !dirExists(absStatic) { - log.Fatalf("static dir not found: %s", *staticDir) - } - api, err := url.Parse(*apiURL) - if err != nil { - log.Fatalf("invalid -api URL %q: %v", *apiURL, err) - } - - // Proxy /api/* to the key-manager (the dev-mode instance bypasses auth). - proxy := httputil.NewSingleHostReverseProxy(api) - - mux := http.NewServeMux() - mux.Handle("/api/", proxy) - mux.HandleFunc("/__livereload", liveReloadHandler(absStatic)) - mux.HandleFunc("/", staticHandler(absStatic)) - - fmt.Printf("UI dev server: http://localhost%s (hot reload)\n", normalizeAddr(*addr)) - fmt.Printf(" serving: %s\n", absStatic) - fmt.Printf(" /api/* proxied: %s\n", api) - if err := http.ListenAndServe(*addr, mux); err != nil { - log.Fatal(err) - } -} - -// staticHandler serves files from dir. For HTML responses it injects a small -// live-reload script before so edits trigger a browser reload. -func staticHandler(dir string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - clean := filepath.Clean(r.URL.Path) - if clean == "/" || clean == "." { - clean = "/index.html" - } - full := filepath.Join(dir, clean) - // Keep the response inside the static dir. - if !strings.HasPrefix(full, dir) { - http.NotFound(w, r) - return - } - if strings.HasSuffix(clean, ".html") { - b, err := os.ReadFile(full) - if err != nil { - http.NotFound(w, r) - return - } - body := injectLiveReload(string(b)) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write([]byte(body)) - return - } - w.Header().Set("Cache-Control", "no-store") - http.ServeFile(w, r, full) - } -} - -const liveReloadScript = `` - -func injectLiveReload(html string) string { - if i := strings.LastIndex(html, ""); i >= 0 { - return html[:i] + liveReloadScript + html[i:] - } - return html + liveReloadScript -} - -// liveReloadHandler streams a reload event whenever the static dir changes. -// It polls a cheap fingerprint (file count + sizes + max mtime) so it needs no -// third-party file-watching dependency. -func liveReloadHandler(dir string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming unsupported", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - last := fingerprint(dir) - ticker := time.NewTicker(400 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-r.Context().Done(): - return - case <-ticker.C: - if fp := fingerprint(dir); fp != last { - last = fp - fmt.Fprint(w, "data: reload\n\n") - flusher.Flush() - } - } - } - } -} - -// fingerprint returns a string that changes whenever a file under dir is added, -// removed, resized, or modified. -func fingerprint(dir string) string { - var b strings.Builder - _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return nil - } - if info, err := d.Info(); err == nil { - fmt.Fprintf(&b, "%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano()) - } - return nil - }) - return b.String() -} - -func dirExists(p string) bool { - info, err := os.Stat(p) - return err == nil && info.IsDir() -} - -func normalizeAddr(addr string) string { - if strings.HasPrefix(addr, ":") { - return addr - } - return ":" + addr -} diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 2ddef5e..f779963 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -14,7 +14,7 @@ To get a model running quickly, see [Quickstart](/quickstart/). The pack deploys a Go operator that watches a custom `LLMModel` CRD. Admins apply one `LLMModel` per model they want to serve. The operator handles everything downstream: model storage, vLLM serving pods, inference scheduling, routing, and access control. -An optional key manager service gives users a web UI to generate API keys for external access. Envoy AI Gateway provides token counting, rate limiting, and protocol normalization on both external and internal endpoints. +An optional key manager gives users a web UI to generate API keys for external access: a React single-page app served by nginx, backed by an API-only Go service. Envoy AI Gateway provides token counting, rate limiting, and protocol normalization on both external and internal endpoints. --- @@ -48,7 +48,7 @@ Installing the chart (pack install) deploys: 1. **LLMModel CRD** - the `llm.nebari.dev/v1alpha1` custom resource definition 2. **LLM operator** - a Go controller (kubebuilder/controller-runtime) that watches `LLMModel` CRs in its own namespace -3. **Key manager** (conditional, `keyManager.enabled`) - a web UI and REST API behind a `NebariApp` with Keycloak/OIDC auth +3. **Key manager** (conditional, `keyManager.enabled`) - a React SPA served by an nginx **frontend** image plus an API-only Go service, fronted by a `NebariApp` with SPA-managed Keycloak/OIDC auth 4. **Envoy AI Gateway** (conditional, `envoyAIGateway.install`) - the controller and CRDs; when `false`, assumes pre-installed The chart creates the operator namespace and labels it `nebari.dev/managed=true` (gated on `createNamespace: true`, default on). @@ -289,16 +289,20 @@ Semantics worth knowing: ## Key manager -A small web application behind `NebariApp` that lets authenticated users generate and manage API keys for models they can access. +Behind `NebariApp` sits a two-part application that lets authenticated users generate and manage API keys for models they can access: a React single-page app served by an nginx **frontend** image, and an API-only Go service. The frontend serves the SPA and a runtime `/config.json` (Keycloak settings), and reverse-proxies `/api/*` to the key-manager's internal ClusterIP over in-cluster DNS. The key-manager Service is internal-only; it is never exposed through the gateway, and the Go service serves `/api/*` only (no static files, no `GET /`). ### How it works -1. User hits the key manager UI at `llm-keys.` -2. Keycloak/OIDC login via `NebariApp` auth -3. Key manager watches all `LLMModel` CRs, filters to models where `access.groups` overlaps with the user's OIDC groups (or `access.public: true`) -4. User sees only models they can access -5. User creates a key for a model; key manager generates `sk-`, writes the client ID and key value to that model's `-api-keys` Secret in the operator namespace, and writes metadata to the corresponding ConfigMap -6. Envoy Gateway's `apiKeyAuth` picks up the new Secret entry immediately +The auth model is **Model B - SPA-managed Keycloak** (no gateway cookie login, no oauth2-proxy sidecar): + +1. The browser loads the SPA at `llm-keys.`. +2. `keycloak-js` performs a `login-required` PKCE (S256) redirect to Keycloak using a **public** client (provisioned by the operator via `spaClient.enabled: true`; no client secret), obtains an access token, and attaches `Authorization: Bearer ` on every `/api` call. +3. nginx forwards the bearer to the key-manager, which **validates the JWT itself** against the Keycloak realm's JWKS: RSA signature, `exp` with 30s leeway, and an exact `iss` match (audience is not checked). Auth is not enforced at the gateway (`enforceAtGateway: false`). +4. The key-manager reads identity and groups from the token claims, watches all `LLMModel` CRs, and filters to models where `access.groups` overlaps with the user's OIDC groups (or `access.public: true`). Users see only models they can access. +5. The user creates a key for a model; the key-manager generates `sk-`, writes the client ID and key value to that model's `-api-keys` Secret in the operator namespace, and writes metadata to the corresponding ConfigMap. +6. Envoy Gateway's `apiKeyAuth` picks up the new Secret entry immediately. + +JWKS validation is configured on the key-manager by three env vars: `LLM_KEYCLOAK_URL` (base URL the JWKS is fetched from), `LLM_KEYCLOAK_REALM`, and `LLM_KEYCLOAK_ISSUER_URL` (the expected `iss` value; defaults to the SPA's external Keycloak URL). These derive from the `keyManager.keycloak.*` Helm values, which in turn default to `frontend.keycloak.*`. Revocation: remove the entry from the Secret and its corresponding metadata from the ConfigMap. Effect is immediate. @@ -347,6 +351,8 @@ The key manager has a scaffolded periodic background audit (configurable interva ### NebariApp integration +The `NebariApp` targets the **frontend** (nginx) Service, not the key-manager. Its auth block is Model B: gateway enforcement is off, and the operator provisions a public PKCE client that the SPA drives itself. + ```yaml apiVersion: reconcilers.nebari.dev/v1 kind: NebariApp @@ -355,7 +361,7 @@ metadata: spec: hostname: llm-keys. # set via keyManager.nebariApp.hostname (no default) service: - name: nebari-llm-serving-key-manager + name: nebari-llm-serving-frontend port: 8080 routing: routes: @@ -363,10 +369,19 @@ spec: auth: enabled: true provider: keycloak # or generic-oidc - provisionClient: true + enforceAtGateway: false # SPA drives login; no gateway cookie/oauth2-proxy + scopes: [openid, profile, email, groups] + keycloakConfig: + protocolMappers: + - type: group-membership # emits the `groups` claim + claim: groups + spaClient: + enabled: true # operator provisions a PUBLIC PKCE client (no secret) gateway: public ``` +There is no gateway `SecurityPolicy`, no oauth2-proxy sidecar, no cookie login, and no `/oauth2/callback` for the key-manager. The SPA obtains the token; the key-manager validates it in-process against Keycloak's JWKS. + --- ## Envoy AI Gateway diff --git a/docs/src/content/docs/cicd-and-releasing.md b/docs/src/content/docs/cicd-and-releasing.md index a1ae350..45d4468 100644 --- a/docs/src/content/docs/cicd-and-releasing.md +++ b/docs/src/content/docs/cicd-and-releasing.md @@ -11,25 +11,27 @@ The repository has six workflows located in `.github/workflows/`. **Triggers:** push to `main`, pull requests targeting `main`. -Runs two parallel jobs: +Runs three parallel jobs: | Job | Component | Command | |-----|-----------|---------| | `test-operator` | `operator/` | `make test` | | `test-key-manager` | `key-manager/` | `go test ./...` | +| `test-frontend` | `frontend/` | `npm test` (vitest) | -Both jobs set up Go from the component's own `go.mod`/`go.sum`, so each component pins its Go version independently. +The two Go jobs set up Go from the component's own `go.mod`/`go.sum`, so each component pins its Go version independently. The `test-frontend` job runs on Node with the `frontend/` npm lockfile. ### Lint (`lint.yaml`) **Triggers:** push to `main`, pull requests targeting `main`. -Runs three parallel jobs: +Runs four parallel jobs: | Job | Component | Tool | |-----|-----------|------| | `lint-operator` | `operator/` | `golangci-lint` v2.4.0 | | `lint-key-manager` | `key-manager/` | `golangci-lint` v2.4.0 | +| `lint-frontend` | `frontend/` | `biome` (`npm run check`) | | `lint-helm` | `charts/nebari-llm-serving/` | `helm lint` | The Helm lint job runs `helm lint charts/nebari-llm-serving/` against the chart without any value overrides, so `platform.baseDomain` defaults to empty and any chart template that gates on it must handle the empty case. @@ -38,13 +40,16 @@ The Helm lint job runs `helm lint charts/nebari-llm-serving/` against the chart **Triggers:** push to `main`, push of any `v*` tag, manual `workflow_dispatch`. -Builds and pushes three images to GitHub Container Registry (`ghcr.io`) under the prefix `ghcr.io/nebari-dev/nebari-llm-serving-pack`: +Builds and pushes four images to GitHub Container Registry (`ghcr.io`) under the prefix `ghcr.io/nebari-dev/nebari-llm-serving-pack`: | Image | Build context | Dockerfile | |-------|--------------|------------| | `ghcr.io/nebari-dev/nebari-llm-serving-pack/operator` | `operator/` | `operator/Dockerfile` | | `ghcr.io/nebari-dev/nebari-llm-serving-pack/model-downloader` | `model-downloader/` | `model-downloader/Dockerfile` | | `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | `.` (repo root) | `key-manager/Dockerfile` | +| `ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend` | `frontend/` | `frontend/Dockerfile` | + +The `build-frontend` job (React SPA → nginx) is added alongside the existing image jobs. Each job uses `docker/metadata-action` to derive tags automatically: @@ -58,7 +63,7 @@ The `latest` tag only applies when building from the default branch. Version tag Authentication to GHCR uses the workflow's automatic `GITHUB_TOKEN` with `packages: write` permission. -The chart's `values.yaml` does not set a default tag for the operator and key-manager images; it leaves `tag: ""` and falls back to `.Chart.AppVersion` at render time. This means `helm install` without a tag override pulls whatever image version matches the chart's `appVersion`. +The chart's `values.yaml` does not set a default tag for the operator, key-manager, and frontend images; it leaves `tag: ""` and falls back to `.Chart.AppVersion` at render time. This means `helm install` without a tag override pulls whatever image version matches the chart's `appVersion`. ### Docs (`docs.yml`) @@ -82,22 +87,23 @@ A single job uses `actions/add-to-project` to automatically add newly opened iss ## Container Images -The three images built by `build-images.yaml` and their roles in the pack: +The four images built by `build-images.yaml` and their roles in the pack: | Image | Purpose | |-------|---------| | `ghcr.io/nebari-dev/nebari-llm-serving-pack/operator` | Kubernetes controller that reconciles `LLMModel` CRs | | `ghcr.io/nebari-dev/nebari-llm-serving-pack/model-downloader` | Init container that downloads model weights into a PVC before the serving pod starts | -| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Web service and UI for managing per-user API keys | +| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager REST API for managing per-user API keys | +| `ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend` | LLM serving pack React UI (nginx) - serves the SPA and proxies `/api` to the key-manager | -A fourth image, `ghcr.io/llm-d/llm-d-cuda`, is the upstream llm-d GPU serving image. Its version is set in `values.yaml` under `defaults.serving.image` and is not built by this repository. See the [llm-d project](https://github.com/llm-d/llm-d) for its release history. +A fifth image, `ghcr.io/llm-d/llm-d-cuda`, is the upstream llm-d GPU serving image. Its version is set in `values.yaml` under `defaults.serving.image` and is not built by this repository. See the [llm-d project](https://github.com/llm-d/llm-d) for its release history. ## Chart Versioning The Helm chart lives at `charts/nebari-llm-serving/` and has two version fields in `Chart.yaml`: - **`version`** - the chart packaging version (SemVer). Helm uses this for `helm repo` indexing and upgrade resolution. -- **`appVersion`** - the application version string, which the chart uses as the default image tag for the operator and key-manager when no explicit tag override is given. +- **`appVersion`** - the application version string, which the chart uses as the default image tag for the operator, key-manager, and frontend when no explicit tag override is given. Both fields are kept in sync; every release bumps them together to the same value (e.g. `0.1.0-alpha.9`). The current version is `0.1.0-alpha.9`. diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 9fe3057..2ee9d44 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -38,7 +38,6 @@ Pass these with `--set key=value` or in a `values.yaml` override file. | `auth.oidc.existingSecret` | `""` | Name of the Secret containing the `issuer-url` key. Defaults to `-oidc-client` when empty. | | `auth.oidc.groupsClaim` | `groups` | JWT claim name used to extract group memberships. | | `auth.oidc.audience` | `""` | Expected audience value in JWT tokens. | -| `auth.oidc.cookiePrefix` | `IdToken` | Cookie name prefix used by the OAuth2 proxy sidecar. | ### Envoy AI Gateway @@ -61,7 +60,7 @@ Pass these with `--set key=value` or in a `values.yaml` override file. | `keyManager.nebariApp.gateway` | `public` | Which Nebari shared gateway to attach the HTTPRoute to. Valid values: `public` or `internal`. | | `keyManager.nebariApp.routing.routes[0].pathPrefix` | `/` | Path prefix for the key-manager UI route. | | `keyManager.nebariApp.routing.routes[0].pathType` | `PathPrefix` | Path match type. | -| `keyManager.nebariApp.auth.provisionClient` | `true` | Create a dedicated Keycloak OIDC client for the UI. | +| `keyManager.nebariApp.auth.spaClientId` | `""` | Optional override for the public PKCE Keycloak client id the operator provisions for the SPA. Empty derives a default. The chart always renders the Model B auth template (`enforceAtGateway: false` + `spaClient.enabled: true`); there is no gateway cookie login to configure. | | `keyManager.nebariApp.auth.scopes` | `[openid, profile, email, groups]` | OIDC scopes requested during login. | | `keyManager.nebariApp.landingPage.enabled` | `true` | Show the key-manager on the Nebari landing page. | | `keyManager.nebariApp.landingPage.displayName` | `"LLM API Keys"` | Display name on the landing page tile. | @@ -70,9 +69,29 @@ Pass these with `--set key=value` or in a `values.yaml` override file. | `keyManager.nebariApp.landingPage.icon` | `"keycloak"` | Tile icon. One of the built-in keys (`jupyter`, `grafana`, `prometheus`, `keycloak`, `argocd`, `kubernetes`) or a URL to a custom image. | | `keyManager.nebariApp.landingPage.priority` | `100` | Sort order on the landing page (lower = higher priority). | | `keyManager.nebariApp.landingPage.healthCheck.enabled` | `true` | Enable active health probing for the landing page tile. | -| `keyManager.nebariApp.landingPage.healthCheck.path` | `/` | Probe path. The key-manager serves its SPA at `/` (HTTP 200, unauthenticated). | +| `keyManager.nebariApp.landingPage.healthCheck.path` | `/` | Probe path. The frontend nginx serves the SPA at `/` (HTTP 200, unauthenticated). | | `keyManager.nebariApp.landingPage.healthCheck.intervalSeconds` | `30` | Probe interval in seconds. | | `keyManager.nebariApp.landingPage.healthCheck.timeoutSeconds` | `5` | Probe timeout in seconds. | +| `keyManager.keycloak.url` | `frontend.keycloak.url` | Base URL the key-manager fetches the Keycloak JWKS from (env `LLM_KEYCLOAK_URL`). Defaults to `frontend.keycloak.url`. | +| `keyManager.keycloak.realm` | `frontend.keycloak.realm` | Keycloak realm used for JWKS lookup and token validation (env `LLM_KEYCLOAK_REALM`). Defaults to `frontend.keycloak.realm`. | +| `keyManager.keycloak.issuerURL` | `frontend.keycloak.url` | Expected `iss` value on incoming JWTs (env `LLM_KEYCLOAK_ISSUER_URL`). Defaults to the SPA's external Keycloak URL. | + +### Frontend + +The React SPA served by nginx. It serves the SPA and a runtime `/config.json`, and reverse-proxies `/api/*` to the key-manager Service. This is the Service the `NebariApp` targets. + +| Key | Default | Description | +|-----|---------|-------------| +| `frontend.enabled` | `true` | Deploy the frontend (nginx) Deployment, Service, and config ConfigMap. | +| `frontend.image.repository` | `ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend` | Frontend container image repository. | +| `frontend.image.tag` | `""` | Image tag. Defaults to `.Chart.AppVersion` when empty. | +| `frontend.image.pullPolicy` | `Always` | Image pull policy. | +| `frontend.port` | `8080` | Container port nginx listens on. | +| `frontend.service.port` | `8080` | Service port for the frontend. | +| `frontend.title` | (chart default) | Title rendered into `/config.json` and shown in the SPA. | +| `frontend.keycloak.url` | `""` | **Required when `frontend.enabled=true`.** External Keycloak base URL the SPA uses for PKCE login (rendered into `/config.json`). | +| `frontend.keycloak.realm` | (chart default) | Keycloak realm the SPA authenticates against. | +| `frontend.keycloak.clientId` | (chart default) | Public PKCE client id the SPA uses. | ### Operator @@ -204,13 +223,15 @@ The fields below map directly to the chart values in the [Key Manager](#key-mana |-----------------|-------------|-------------| | `spec.hostname` | `keyManager.nebariApp.hostname` | Fully qualified hostname. Required. | | `spec.gateway` | `keyManager.nebariApp.gateway` | `public` or `internal`. | -| `spec.service.name` | (chart-derived) | Service name for the key-manager backend. | +| `spec.service.name` | (chart-derived) | Service name for the **frontend** (nginx) backend the NebariApp routes to. | | `spec.service.port` | `8080` | Fixed service port. | | `spec.routing` | `keyManager.nebariApp.routing` | Passed through verbatim. Without this block the operator sets `RoutingReady=False` and skips HTTPRoute and Certificate provisioning. | | `spec.auth.enabled` | `true` (fixed) | Authentication always enabled for the key-manager. | | `spec.auth.provider` | `keycloak` (fixed) | Auth provider. | -| `spec.auth.provisionClient` | `keyManager.nebariApp.auth.provisionClient` | When `true`, creates a dedicated Keycloak client and writes credentials to `-oidc-client` Secret. | -| `spec.auth.scopes` | `keyManager.nebariApp.auth.scopes` | OIDC scopes requested. | +| `spec.auth.enforceAtGateway` | `false` (fixed) | Model B: the gateway does not enforce auth. The SPA drives Keycloak PKCE login and the key-manager validates the bearer against JWKS. No oauth2-proxy sidecar or cookie login. | +| `spec.auth.spaClient.enabled` | `true` (fixed) | The operator provisions a **public** PKCE Keycloak client (no secret) for the SPA. | +| `spec.auth.keycloakConfig.protocolMappers` | (fixed) | A group-membership mapper that emits the `groups` claim. | +| `spec.auth.scopes` | `keyManager.nebariApp.auth.scopes` | OIDC scopes requested (`openid, profile, email, groups`). | | `spec.landingPage.enabled` | `keyManager.nebariApp.landingPage.enabled` | Show tile on Nebari landing page. | | `spec.landingPage.displayName` | `keyManager.nebariApp.landingPage.displayName` | Tile display name. Required when `enabled: true`. | | `spec.landingPage.description` | `keyManager.nebariApp.landingPage.description` | Tile description. | diff --git a/docs/src/content/docs/installation.md b/docs/src/content/docs/installation.md index 050af33..a58c593 100644 --- a/docs/src/content/docs/installation.md +++ b/docs/src/content/docs/installation.md @@ -1001,11 +1001,14 @@ kubectl get certificate -n nebari-llm-serving-system nebari-llm-shared-tls Expected `READY=True`. -The key-manager UI is reachable at `https://llm-keys./`, -gated by Keycloak OAuth2 (members of the `llm` group only). Hitting -that URL in a browser at this point should redirect through the -Keycloak login screen and bounce back to a (mostly empty) key-manager -page. There are no LLMModels yet; section 9 changes that. +The key-manager UI is reachable at `https://llm-keys./`. +It is a React single-page app (served by nginx) that drives its own +Keycloak login: `keycloak-js` performs a PKCE redirect to Keycloak, +and the SPA then calls the key-manager API with a bearer token. Hitting +that URL in a browser at this point should send you through the +Keycloak login screen and back to a (mostly empty) key-manager page +for users in the `llm` group. There are no LLMModels yet; section 9 +changes that. ## 9. Apply your first LLMModel @@ -1361,45 +1364,41 @@ curl -sS -H "Authorization: Bearer $TOKEN" \ Expected: a single entry with `"name": "llm"`. -The Keycloak clients reconciled by the operator from NebariApps: +The Keycloak client reconciled by the operator from the key-manager NebariApp: ```bash curl -sS -H "Authorization: Bearer $TOKEN" \ "https://keycloak./admin/realms/nebari/clients?clientId=nebari-llm-serving-key-manager" \ - | python3 -m json.tool | head -20 + | python3 -m json.tool | head -30 ``` -Expected: a client with the same `clientId` and `redirectUris` -covering `https://llm-keys./oauth2/callback`. +Expected: a **public** client (`"publicClient": true`, no client +secret) with `"standardFlowEnabled": true` and `redirectUris` covering +the SPA origin `https://llm-keys./*`. This is the PKCE +client the SPA logs in with; there is no `/oauth2/callback` redirect +because the pack no longer uses an oauth2-proxy cookie flow. -### 10.9 SecurityPolicy OIDC endpoints (browser-facing vs back-channel) +### 10.9 Key-manager JWT validation (Model B) -> **Beta documentation gate - dual-endpoint auth:** The pack uses two -> different Keycloak URL forms for the SecurityPolicy OIDC config. -> Browser-facing endpoints (`authorizationEndpoint`, `endSessionEndpoint`) -> must use the public `https://keycloak./...` URL so that -> browser redirects resolve. Back-channel endpoints (`tokenEndpoint`, -> `issuer`) use the in-cluster `http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080/...` -> URL for performance and to avoid hairpinning through the public Gateway. -> This split is implemented in nebari-operator >= v0.1.0-alpha.19. If -> your cluster has an older operator, section 12.2 documents the -> workaround. +The key-manager UI uses **Model B - SPA-managed Keycloak**: the SPA +obtains a bearer token via PKCE and sends it on `/api` calls, and the +key-manager validates that JWT itself against the realm's JWKS. There +is **no gateway `SecurityPolicy`** on the key-manager host, so there is +no gateway OIDC/cookie config to inspect. Instead, confirm the +key-manager is pointed at the right Keycloak realm and issuer: ```bash -kubectl get securitypolicy -n nebari-llm-serving-system nebari-llm-serving-key-manager-security \ - -o jsonpath='{.spec.oidc.provider}' | python3 -m json.tool +kubectl get deploy -n nebari-llm-serving-system nebari-llm-serving-key-manager \ + -o jsonpath='{range .spec.template.spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}' \ + | grep -E '^LLM_KEYCLOAK_' ``` -Expected (the WL-3 fix from `nebari-operator v0.1.0-alpha.19+`): - -- `authorizationEndpoint`: `https://keycloak./...auth` (PUBLIC) -- `endSessionEndpoint`: `https://keycloak./...logout` (PUBLIC) -- `tokenEndpoint`: `http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080/...token` (in-cluster) -- `issuer`: in-cluster (until upstream nebari-operator - #112 ships) - -If the public endpoints still point at `keycloak-keycloakx-http.keycloak.svc.cluster.local`, -your NIC operator is older than `v0.1.0-alpha.19`; bump it. +Expected: `LLM_KEYCLOAK_URL` (the base URL JWKS is fetched from), +`LLM_KEYCLOAK_REALM=nebari`, and `LLM_KEYCLOAK_ISSUER_URL` set to the +public Keycloak realm URL (`https://keycloak./realms/nebari`) +- this is the exact `iss` value the key-manager requires on incoming +tokens. The key-manager checks the RSA signature, `exp` (with 30s +leeway), and an exact `iss` match; audience is not checked. ### 10.10 Browser smoke test @@ -1408,11 +1407,15 @@ cookies), open: - `https:///` -> NIC landing page should render with a tile for `nebari-llm-serving-key-manager`. -- `https://llm-keys./` -> Keycloak login screen, then - the key-manager UI for users in the `llm` group. +- `https://llm-keys./` -> the SPA loads and immediately + redirects to the Keycloak login screen (PKCE), then returns to the + key-manager UI for users in the `llm` group. -If the browser dead-ends on `keycloak-keycloakx-http.keycloak.svc.cluster.local`, -re-run check 10.9. +The SPA reads its Keycloak URL from the runtime `/config.json` served +by nginx, so login always uses the public `https://keycloak.` +URL. If the model list is empty or `/api` calls return 401, re-run +check 10.9 to confirm the key-manager's `LLM_KEYCLOAK_ISSUER_URL` +matches the token issuer. If every check passes, the install is good. Section 11 walks through the actual end-user journeys (mint a key, hit the external @@ -1578,9 +1581,21 @@ NIC should ship with `cert-manager.maxConcurrentChallenges: 1` to prevent HTTP-01 solver races on overlapping SANs (nebari-dev/nebari-infrastructure-core#259). -### 12.2 SecurityPolicy uses in-cluster Keycloak URLs for browser-facing endpoints - -**Symptom:** The key-manager UI redirects the browser to +### 12.2 SecurityPolicy uses in-cluster Keycloak URLs for browser-facing endpoints (obsolete for the key-manager) + +> **No longer applies to the key-manager.** This was an issue with the +> old gateway oauth2-proxy cookie flow, where an Envoy `SecurityPolicy` +> injected browser-facing OIDC endpoints. The key-manager now uses +> **Model B - SPA-managed Keycloak** (`enforceAtGateway: false`, a +> public PKCE client, and in-app JWKS validation), so there is no +> gateway `SecurityPolicy` on the key-manager host and no in-cluster +> vs. public endpoint split to get wrong. The SPA reads its Keycloak +> URL from `/config.json` and always uses the public +> `https://keycloak.` URL. Retained here only for clusters +> still running other apps on the older cookie flow. + +**Symptom (legacy cookie flow):** A UI behind the gateway oauth2-proxy +redirects the browser to `http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080/...` instead of the public `https://keycloak./...` URL. The OAuth2 flow dead-ends because browsers cannot resolve in-cluster @@ -1594,10 +1609,6 @@ This version uses `KEYCLOAK_EXTERNAL_URL` for the browser hits) while keeping `tokenEndpoint` and `issuer` as in-cluster URLs (back-channel only). -If your NIC ships an older nebari-operator, override the image in -your NIC kustomization or ArgoCD values until the fix is released -upstream. - ### 12.3 AI Gateway webhook certificate becomes untrusted after pod rescheduling **Symptom:** The envoy proxy deployment diff --git a/docs/src/content/docs/local-development.md b/docs/src/content/docs/local-development.md index 5ff535a..a8253de 100644 --- a/docs/src/content/docs/local-development.md +++ b/docs/src/content/docs/local-development.md @@ -145,23 +145,26 @@ The key manager exposes an HTTP API for generating and revoking API keys. In the kubectl -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 & ``` -List models (requires a JWT in the `Authorization` header or an identity cookie): +On the dev cluster the key-manager runs with `LLM_DEV_MODE=true`, which +bypasses auth and injects a fixed identity (user `dev`, group `llm`). No token +is required - list models directly: ```bash -# With a fake JWT (the dev server accepts any token for testing) -curl -s http://localhost:8080/api/models \ - -H "Authorization: Bearer fake-jwt-token" | jq . +curl -s http://localhost:8080/api/models | jq . ``` Create an API key for the test model: ```bash curl -s -X POST http://localhost:8080/api/keys \ - -H "Authorization: Bearer fake-jwt-token" \ -H "Content-Type: application/json" \ -d '{"modelName": "test-model"}' | jq . ``` +Outside dev mode the key-manager validates a real Keycloak bearer token on +every `/api` call (RSA signature + `exp` + `iss`), which the SPA obtains via +PKCE. + The response includes the generated key. Keys are stored as Kubernetes Secrets in the operator namespace (defaults to `llm-operator-system` for the dev cluster, `nebari-llm-serving-system` for the chart): ```bash @@ -207,23 +210,31 @@ JWT, even when access is public, so it is not reachable on a bare kind cluster. ## 10. Open the key-manager UI (dev mode) -The deployed key-manager runs in dev mode on kind: it bypasses auth and injects a -fixed identity (user `dev`, groups `["llm"]`), because there is no Keycloak or -gateway OIDC layer in front of it. Forward its port and open the UI: +The UI is a React + TypeScript SPA in `frontend/`, served in production by its +own nginx image. For local work, run it with the Vite dev server against the +dev-mode key-manager - one command wires up the cluster, models, port-forward, +and hot reload: ```bash -make ui # serves http://localhost:8080 +./run-dev.sh # from dev/ · also: make run-dev · make ui (repo root) ``` +This port-forwards the key-manager API to `http://localhost:8080` and starts the +Vite dev server at `http://localhost:5173` (develop there). Two things make login +disappear locally: + +- The dev server sets `VITE_DEV_NO_AUTH=true`, so the SPA skips the keycloak-js + PKCE redirect. +- The deployed key-manager runs with `LLM_DEV_MODE=true`, which bypasses auth + and injects a fixed identity (user `dev`, group `llm`). + The UI loads without a login and can mint and revoke keys for any model the `dev` identity's groups grant access to. Dev mode is controlled by -`LLM_DEV_MODE` on the key-manager Deployment (and `keyManager.devMode.enabled` -in the Helm chart); it is off by default and must never be enabled in a real -deployment. +`LLM_DEV_MODE` on the key-manager Deployment; it is off by default and must +never be enabled in a real deployment. -> **Working on the UI itself?** Use `make run-dev` instead of the steps above: -> one command brings up the cluster, three models, the port-forward, and a -> hot-reloading dev server. See [UI Development](/ui-development/). +> **Working on the UI itself?** See [UI Development](/ui-development/) for the +> full hot-reload loop and the frontend quality gate. ## 11. Tail logs diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index 57e57c2..5caab3a 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -85,9 +85,9 @@ All PVCs should be `Bound`. If any is `Pending`, check your `storageClass` setti ## Key-manager returns 403 - user not in the allowed Keycloak group -**Symptom:** A user logs into `https://llm-keys./` successfully (Keycloak authentication succeeds) but sees no models listed, or receives an HTTP 403 when trying to create an API key. +**Symptom:** A user opens `https://llm-keys./` and the SPA's keycloak-js PKCE login succeeds (Keycloak authentication works), but they see no models listed, or receive an HTTP 403 when trying to create an API key. -**Likely cause:** The user's Keycloak account is not a member of the group named in `LLMModel.spec.access.groups` (default: `llm`). The key manager checks group membership from the `groups` claim in the user's JWT on every request. +**Likely cause:** The user's Keycloak account is not a member of the group named in `LLMModel.spec.access.groups` (default: `llm`). The key manager validates the bearer token on every `/api` request and checks group membership from the `groups` claim. **Fix:** diff --git a/docs/src/content/docs/ui-development.md b/docs/src/content/docs/ui-development.md index 68ead8b..80e24c9 100644 --- a/docs/src/content/docs/ui-development.md +++ b/docs/src/content/docs/ui-development.md @@ -7,19 +7,25 @@ the gateway, or Kubernetes to use it. ## What you get -`make run-dev` brings up a local [kind](https://kind.sigs.k8s.io/) cluster with -the operator, the key-manager (in dev mode, so no Keycloak login), and three -OpenRouter passthrough models, then: +The UI is a React 19 + TypeScript app (Vite, Tailwind, shadcn/ui) that lives in +`frontend/` at the repo root. It is a standalone single-page app: in production +it is built to a static bundle and served by its own nginx container image, and +it talks to the key-manager only over `/api/*`. -- port-forwards the key-manager to `http://localhost:8080` (its embedded UI + API), and -- starts a hot-reload dev server at `http://localhost:5173` that serves the UI - from your working copy and reloads the browser when you edit it. +`make run-dev` (or `cd dev && ./run-dev.sh`) brings up a local +[kind](https://kind.sigs.k8s.io/) cluster with the operator, the key-manager (in +dev mode, so no Keycloak login), and three OpenRouter passthrough models, then: + +- port-forwards the key-manager API to `http://localhost:8080`, and +- starts the Vite dev server at `http://localhost:5173` with hot module reload, + proxying `/api/*` to the port-forwarded key-manager. You develop against `http://localhost:5173`. ## Prerequisites - Docker, [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation), kubectl, helm, and Go 1.25+. +- [Node.js](https://nodejs.org) 20+ and npm (for the Vite dev server). - An [OpenRouter API key](https://openrouter.ai/keys). ## Setup @@ -28,57 +34,71 @@ You develop against `http://localhost:5173`. cd dev cp .env.example .env # edit .env and set OPENROUTER_API_KEY=sk-or-v1-... -make run-dev +./run-dev.sh # or: make run-dev (from dev/) · make ui (from repo root) ``` First run creates the cluster and builds images, so it takes a few minutes. Later runs reuse the cluster and start in seconds. When it is ready you will see: ``` -key-manager (embedded UI + API): http://localhost:8080 -hot-reload UI dev server: http://localhost:5173 <-- develop here +key-manager API: http://localhost:8080 +Vite dev server: http://localhost:5173 <-- develop here ``` -Open `http://localhost:5173`. You are signed in automatically as user `dev` -(group `llm`), and the model list shows `claude-sonnet-45`, `gemini-25-flash`, -and `llama-33-70b`. Mint and revoke keys against them as a real user would. +Open `http://localhost:5173`. The dev server runs with `VITE_DEV_NO_AUTH=true`, +so the Keycloak PKCE login is bypassed and you are signed in automatically as +user `dev` (group `llm`). The model list shows `claude-sonnet-45`, +`gemini-25-flash`, and `llama-33-70b`. Mint and revoke keys against them as a +real user would. Press Ctrl-C to stop; the port-forward is cleaned up for you. The cluster keeps running for the next `make run-dev`. ## Editing the UI -The UI is plain static files (no build step): +The React source lives under `frontend/src/`. Edit any file there and the Vite +dev server at `:5173` hot-reloads the browser. The dev server proxies `/api/*` +to the port-forwarded key-manager, so the UI talks to a real backend (real +models, real key minting) while you iterate on components, styles, and state. -``` -key-manager/internal/ui/static/ - index.html - app.js - style.css -``` +### Auth in dev mode -Edit any of them and the browser at `:5173` reloads automatically. The dev -server proxies `/api/*` to the port-forwarded key-manager, so the UI talks to a -real backend (real models, real key minting) while you iterate on markup, styles, -and JavaScript. +In production the SPA owns login: `keycloak-js` performs a `login-required` PKCE +redirect to Keycloak, obtains an access token, and the app sends +`Authorization: Bearer ` on every `/api` call. The dev loop shortcuts +this on both ends: -### Auth in dev mode +- The Vite dev server sets `VITE_DEV_NO_AUTH=true`, which skips the keycloak-js + redirect so you never hit a login screen. +- The in-cluster key-manager runs with `LLM_DEV_MODE=true`, which bypasses auth + and injects a fixed identity (`dev`, group `llm`). So `/api/me` returns that + identity and every `/api/*` call works without a token. -The key-manager normally sits behind Keycloak and the gateway's OIDC layer. On -this cluster it runs with `LLM_DEV_MODE=true`, which bypasses auth and injects a -fixed identity (`dev`, group `llm`). So `/api/me` returns that identity and every -`/api/*` call works without a token. Never enable dev mode in a real deployment. +Never enable dev mode in a real deployment. + +## Frontend quality gate + +Before opening a PR, run the same checks CI runs, from `frontend/`: + +```bash +cd frontend +npm run build # tsc -b && vite build +npm test # vitest +npm run check # biome lint + format +``` ## Shipping a change -The static files are compiled into the key-manager binary with `go:embed`, so -committing your edits is all that is needed for them to ship in the next image +The UI ships as its own image +(`ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend`, nginx serving the built +bundle) - it is no longer embedded in the Go key-manager binary. Committing your +edits to `frontend/` is all that is needed for them to ship in the next image build. To see your changes in the actual in-cluster pod (rather than the dev server), rebuild and reload: ```bash make build-images && make load-images -kubectl -n llm-operator-system rollout restart deployment/llm-key-manager +kubectl -n llm-operator-system rollout restart deployment/llm-frontend ``` ## API reference (what the UI calls) @@ -95,10 +115,10 @@ All under `/api`, all gated by auth (bypassed in dev mode): ## Troubleshooting -- **`SSL_ERROR_RX_RECORD_TOO_LONG` / "Secure Connection Failed"** - the UI is - plain HTTP. Use `http://localhost:5173` (or `:8080`), not `https://`. If your - browser force-upgrades localhost to HTTPS, use a private window or disable - HTTPS-Only mode for the site. +- **`SSL_ERROR_RX_RECORD_TOO_LONG` / "Secure Connection Failed"** - the dev + server is plain HTTP. Use `http://localhost:5173` (or `:8080`), not `https://`. + If your browser force-upgrades localhost to HTTPS, use a private window or + disable HTTPS-Only mode for the site. - **Model list is empty** - the key-manager resyncs models every 30s; give it a moment after `make run-dev`, or check `kubectl -n llm-operator-system get passthroughmodel`. - **`make run-dev` says `OPENROUTER_API_KEY is not set`** - create `dev/.env` diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md index 1841892..f8bf737 100644 --- a/frontend-rewrite-plan.md +++ b/frontend-rewrite-plan.md @@ -25,10 +25,15 @@ Tracking issue: **#92 — Rebuild the LLM Serving Pack UI with React + TypeScrip `Authorization: Bearer ` on every `/api` call (refresh on 401, retry once, else redirect to login). Keycloak `{ url, realm, clientId }` is loaded at runtime from **`/config.json`** (Helm-rendered, mounted into nginx — no - rebuild to change). The gateway shifts from oauth2-proxy cookie injection to - an Envoy **JWT `SecurityPolicy`** that validates the bearer; the key-manager - middleware already accepts `Authorization: Bearer` and parses claims (Envoy - validates the signature upstream) — so the Go backend is unchanged. + rebuild to change). **Correction to the original plan:** auth is **not** + enforced at the gateway. The `NebariApp` uses `enforceAtGateway: false` + + `spaClient.enabled: true` (the operator provisions a public PKCE client), so + there is no Envoy JWT `SecurityPolicy` and no oauth2-proxy sidecar. Instead the + **key-manager validates the bearer in-process** against the Keycloak realm's + JWKS (RSA signature + `exp` with 30s leeway + exact `iss` match; audience not + checked), reading identity/groups from the claims — a real backend change, + mirroring nebari-landing. It is configured by `LLM_KEYCLOAK_URL`, + `LLM_KEYCLOAK_REALM`, and `LLM_KEYCLOAK_ISSUER_URL`. ## Reference template — nebari-landing (`~/repos/nebari-landing`) @@ -79,9 +84,12 @@ API under one host for simplicity and so the JWT `SecurityPolicy` covers both. **Deployment approach:** the **nginx frontend container is the service the NebariApp targets**; nginx serves the SPA + `/config.json` and reverse-proxies `/api/*` to the key-manager ClusterIP (`:8080`). The Go key-manager drops static -serving and becomes API-only. The gateway enforces an Envoy **JWT -`SecurityPolicy`** (validates the bearer) instead of injecting an `IdToken` -cookie; key-manager parses the already-validated bearer. +serving and becomes API-only. **As built (corrected):** the gateway does **not** +enforce auth (`enforceAtGateway: false`, public `spaClient`) — there is no Envoy +JWT `SecurityPolicy`. The **key-manager validates the bearer itself** against +Keycloak's JWKS, so it now verifies the signature (previously it only parsed +claims). The key-manager Service stays internal-only and is never exposed +through the gateway. Login/logout are driven by `keycloak-js` in the SPA (redirect to Keycloak), so the old `/logout` proxy route is replaced by `keycloak.logout()`. @@ -150,20 +158,22 @@ Mirrors nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. > Note: `src/hooks/useCurrentUser.ts` (GET /api/me) is no longer used by the UI (identity now > comes from the ID token) but kept as a valid endpoint wrapper. Remove in cleanup if still unused. -### Phase 5 — Serve separately (Docker + Helm + CI) -- [ ] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` +### Phase 5 — Serve separately (Docker + Helm + CI) ✅ +- [x] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` `proxy_pass` to key-manager service) -- [ ] `frontend/nginx.conf` — also serves `/config.json` (Helm-rendered, mounted) -- [ ] Gut `key-manager/internal/ui/` (remove `embed.go` + `static/`); drop file - server / SPA route from `cmd/main.go` → key-manager becomes API-only -- [ ] Helm: new `frontend` Deployment + Service (nginx :80, backend URL via env) -- [ ] Helm: render `/config.json` (ConfigMap from `frontend.keycloak.*` values) into nginx -- [ ] Repoint `key-manager-nebariapp.yaml` `service:` at the frontend service -- [ ] Gateway: switch NebariApp auth from cookie injection to a JWT `SecurityPolicy` - validating the bearer (Model B) -- [ ] Add `frontend.image.*` + `frontend.keycloak.*` to `values.yaml` -- [ ] CI: `build-frontend` job in `build-images.yaml` -- [ ] CI: `lint-frontend` + test job (biome + vitest) in `lint.yaml` / `test.yaml` +- [x] `frontend/nginx.conf` — also serves `/config.json` (Helm-rendered, mounted) +- [x] Gut `key-manager/internal/ui/` (remove `embed.go` + `static/`); drop file + server / SPA route from `cmd/main.go` → key-manager is API-only +- [x] Helm: new `frontend` Deployment + Service + config ConfigMap +- [x] Helm: render `/config.json` (ConfigMap from `frontend.keycloak.*` values) into nginx +- [x] Repoint `key-manager-nebariapp.yaml` `service:` at the frontend service +- [x] Gateway auth is **Model B without a gateway `SecurityPolicy`**: NebariApp + `enforceAtGateway: false` + `spaClient.enabled: true`; the **key-manager** + validates the bearer against Keycloak JWKS (`LLM_KEYCLOAK_*` env). (Not the + original Envoy JWT `SecurityPolicy` plan.) +- [x] Add `frontend.image.*` + `frontend.keycloak.*` (and `keyManager.keycloak.*`) to `values.yaml` +- [x] CI: `build-frontend` job in `build-images.yaml` +- [x] CI: `lint-frontend` (biome) + `test-frontend` (vitest) jobs in `lint.yaml` / `test.yaml` ### Phase 6 — Local dev - [x] Dev Keycloak in the kind cluster: `dev/manifests/keycloak.yaml` (`start-dev @@ -171,8 +181,12 @@ Mirrors nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. PKCE client `nebari-frontend-spa`, groups mapper, `testuser`/`testuser`); `make deploy-keycloak` renders the realm ConfigMap + deploys; `make pf-keycloak` (8180) / `pf-key-manager` (8080) port-forward helpers -- [ ] Wire `frontend/` into `dev/` (Makefile / manifests) alongside the backend -- [ ] Document Vite `/api` proxy + local `config.json` + how Keycloak/bearer auth is +- [x] Wire `frontend/` into `dev/`: `cd dev && ./run-dev.sh` (also `make run-dev` + / `make ui`) brings up the kind cluster + dev-mode key-manager + (`LLM_DEV_MODE=true`) + Vite dev server at :5173 with `VITE_DEV_NO_AUTH=true`, + proxying `/api/*` to the port-forwarded key-manager on :8080. The old + `go:embed` static UI and the `dev/uidev` Go live-reload server are removed. +- [x] Document Vite `/api` proxy + local `config.json` + how Keycloak/bearer auth is stubbed for standalone runs (E2E auth shim or a real Keycloak) > Inner loop: `cd dev && make setup build-images load-images deploy deploy-keycloak`, @@ -184,21 +198,25 @@ Mirrors nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. > -d grant_type=password http://localhost:8180/realms/nebari/protocol/openid-connect/token` ### Phase 7 — Quality gate, cleanup, docs -- [ ] `npm run build && npm run test:run && npm run check` all pass -- [ ] Remove old vanilla files only after parity confirmed -- [ ] Refresh `docs/install-production-screenshots/*` + README / getting-started UI references +- [x] `npm run build && npm run test:run && npm run check` all pass +- [x] Remove old vanilla files (`key-manager/internal/ui/`, `dev/uidev`) after parity confirmed +- [x] Refresh README / getting-started UI references (screenshots unchanged) --- -## Open items to confirm during build - -- [ ] Can `NebariApp` route two services under one host? (Would enable the fallback - and let us drop the nginx `/api` proxy.) Default assumes **no**. -- [ ] Confirm the key-manager ClusterIP service name/port the nginx proxy targets. -- [ ] Confirm the gateway supports a JWT `SecurityPolicy` for the key-manager host - (Model B) and where the Keycloak realm/clientId for the serving pack come from. -- [ ] Decide whether key-manager should validate the bearer signature itself, or keep - trusting the gateway (current middleware does not verify signatures). +## Open items — resolved during build + +- [x] `NebariApp` routes a **single** service under the host (the frontend nginx), + which reverse-proxies `/api/*` to the key-manager — so the nginx `/api` proxy + stays. (No two-services-per-host routing needed.) +- [x] The nginx proxy targets the internal key-manager ClusterIP Service on `:8080` + over in-cluster DNS; that Service is never exposed through the gateway. +- [x] Gateway does **not** use a JWT `SecurityPolicy` for the key-manager host. + Auth is `enforceAtGateway: false` + a public `spaClient`; the SPA reads + `keycloak.{url,realm,clientId}` from `/config.json` (from `frontend.keycloak.*`). +- [x] **Decided: the key-manager validates the bearer signature itself** against + Keycloak JWKS (`LLM_KEYCLOAK_*`), rather than trusting the gateway. This was a + real backend change (the middleware now verifies signatures). --- diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..39fa2a5 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +*.log +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c3eb3b0 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,33 @@ +# ─── Stage 1: build ────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install dependencies first to leverage layer caching. +COPY package*.json ./ +RUN npm ci + +# Copy the rest of the frontend source and build. Vite outputs to /app/dist, +# including the placeholder public/config.json (overridden at deploy time by the +# Helm-rendered ConfigMap mount). +COPY ./ ./ +RUN npm run build + +# ─── Stage 2: serve ────────────────────────────────────────────────────────── +FROM nginx:alpine AS final + +# Remove the default nginx welcome page and config. +RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf + +# Copy built assets. +COPY --from=builder /app/dist /usr/share/nginx/html + +# Baked placeholder nginx config so the image runs standalone (no /api proxy — +# that is added by the Helm-rendered nginx.conf mounted over this in-cluster). +# Listens on 8080 with all temp paths under /tmp so the container can run +# non-root with a read-only root filesystem. +COPY nginx.default.conf /etc/nginx/nginx.conf + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.default.conf b/frontend/nginx.default.conf new file mode 100644 index 0000000..8315309 --- /dev/null +++ b/frontend/nginx.default.conf @@ -0,0 +1,63 @@ +# Placeholder nginx config baked into the image so it runs standalone. +# +# In-cluster this file is replaced by the Helm-rendered nginx.conf (mounted from +# the frontend ConfigMap), which adds the /api/ reverse-proxy to the key-manager. +# This baked copy only serves the SPA + /config.json + /healthz. +# +# Listens on 8080 with all writable paths under /tmp so the container can run as +# a non-root user with a read-only root filesystem. +worker_processes auto; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp_path; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + sendfile on; + keepalive_timeout 65; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml application/xml+rss text/javascript + image/svg+xml; + gzip_min_length 1024; + + # Cache content-hashed static assets aggressively. + location ~* \.(js|css|png|svg|ico|woff2?|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # SPA fallback. + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/key-manager/cmd/main.go b/key-manager/cmd/main.go index 5719447..63e2f4f 100644 --- a/key-manager/cmd/main.go +++ b/key-manager/cmd/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "io/fs" "log" "log/slog" "net/http" @@ -24,20 +23,25 @@ import ( "github.com/nebari-dev/nebari-llm-serving-pack/key-manager/internal/audit" "github.com/nebari-dev/nebari-llm-serving-pack/key-manager/internal/models" "github.com/nebari-dev/nebari-llm-serving-pack/key-manager/internal/secrets" - "github.com/nebari-dev/nebari-llm-serving-pack/key-manager/internal/ui" ) func main() { // 1. Parse config from env vars. apiKeysNamespace := getEnvOrDefault("LLM_API_KEYS_NAMESPACE", "llm-api-keys") groupsClaim := getEnvOrDefault("LLM_OIDC_GROUPS_CLAIM", "groups") - cookiePrefix := getEnvOrDefault("LLM_AUTH_COOKIE_PREFIX", "IdToken") auditIntervalStr := getEnvOrDefault("LLM_AUDIT_INTERVAL", "5m") oidcUserinfoURL := os.Getenv("LLM_OIDC_USERINFO_URL") // optional listenAddr := getEnvOrDefault("LLM_LISTEN_ADDR", ":8080") devMode := strings.EqualFold(os.Getenv("LLM_DEV_MODE"), "true") devUser := getEnvOrDefault("LLM_DEV_USER", "dev") devGroups := splitAndTrim(getEnvOrDefault("LLM_DEV_GROUPS", "llm")) + // Model B (SPA-managed Keycloak): the key-manager verifies bearer tokens + // against the realm's JWKS. keycloakURL is the internal cluster URL used to + // fetch JWKS; keycloakIssuerURL (optional) is the external URL embedded in + // tokens as `iss` and defaults to keycloakURL when unset. + keycloakURL := os.Getenv("LLM_KEYCLOAK_URL") + keycloakRealm := getEnvOrDefault("LLM_KEYCLOAK_REALM", "nebari") + keycloakIssuerURL := os.Getenv("LLM_KEYCLOAK_ISSUER_URL") auditInterval, err := time.ParseDuration(auditIntervalStr) if err != nil { @@ -105,22 +109,15 @@ func main() { logger.Info("audit loop disabled: LLM_OIDC_USERINFO_URL not set") } - // 8. Set up HTTP mux and routes. + // 8. Set up HTTP mux and routes. The key-manager is API-only; the React UI + // is served by a separate nginx image that reverse-proxies /api here. mux := http.NewServeMux() handler.RegisterRoutes(mux) - // Serve static UI files at root. - staticFS, err := fs.Sub(ui.StaticFiles, "static") - if err != nil { - log.Fatalf("creating sub-FS for static files: %v", err) - } - mux.Handle("GET /", http.FileServer(http.FS(staticFS))) - // Apply auth middleware only to /api/ routes. authConfig := api.AuthConfig{ - GroupsClaim: groupsClaim, - CookiePrefix: cookiePrefix, - DevMode: devMode, + GroupsClaim: groupsClaim, + DevMode: devMode, } if devMode { authConfig.DevIdentity = api.UserInfo{ @@ -131,6 +128,14 @@ func main() { } logger.Warn("LLM_DEV_MODE is enabled: auth is bypassed and a fixed identity is injected; never enable this in a real deployment", "username", devUser, "groups", devGroups) + } else { + if keycloakURL == "" { + log.Fatal("LLM_KEYCLOAK_URL is required unless LLM_DEV_MODE=true") + } + validator := api.NewJWTValidator(keycloakURL, keycloakRealm, logger) + validator.SetIssuerURL(keycloakIssuerURL) + authConfig.Validator = validator + logger.Info("bearer-token auth enabled", "keycloakURL", keycloakURL, "realm", keycloakRealm) } authMW := api.AuthMiddleware(authConfig) diff --git a/key-manager/go.mod b/key-manager/go.mod index aaf40df..9b39423 100644 --- a/key-manager/go.mod +++ b/key-manager/go.mod @@ -5,20 +5,24 @@ go 1.25.8 replace github.com/nebari-dev/nebari-llm-serving-pack/operator => ../operator require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/nebari-dev/nebari-llm-serving-pack/operator v0.0.0 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 + sigs.k8s.io/controller-runtime v0.23.3 +) + +require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -26,36 +30,22 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nebari-dev/nebari-llm-serving-pack/operator v0.0.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.35.0 // indirect - k8s.io/apiextensions-apiserver v0.35.0 // indirect - k8s.io/apimachinery v0.35.0 // indirect - k8s.io/client-go v0.35.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect - sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect diff --git a/key-manager/go.sum b/key-manager/go.sum index 59e3630..97e73b6 100644 --- a/key-manager/go.sum +++ b/key-manager/go.sum @@ -1,3 +1,5 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -10,12 +12,12 @@ github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= @@ -24,13 +26,17 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -38,8 +44,11 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -51,6 +60,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -61,21 +74,33 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -90,11 +115,12 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= diff --git a/key-manager/internal/api/jwt_validator.go b/key-manager/internal/api/jwt_validator.go new file mode 100644 index 0000000..7fa0970 --- /dev/null +++ b/key-manager/internal/api/jwt_validator.go @@ -0,0 +1,333 @@ +package api + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math/big" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// retryMaxAttempts and retryInitialBackoff control the *active* JWKS fetch +// retry loop run on the background init goroutine. After this budget is +// exhausted the goroutine switches to slowPollInterval to keep trying +// indefinitely without the exponential blow-up. They are package-level +// variables so tests can override them without incurring real sleep time. +var ( + retryMaxAttempts = 5 + retryInitialBackoff = 2 * time.Second + // retryDelay is called between attempts; replaced in tests to avoid sleeping. + retryDelay = time.Sleep + // slowPollInterval is the cadence for the post-retry "keep trying" loop. + slowPollInterval = 30 * time.Second +) + +// ErrNotReady is returned by ValidateToken when the validator's initial JWKS +// fetch has not completed yet. Callers should treat this as transient and +// surface a 503 Service Unavailable so clients distinguish "Keycloak not +// reachable yet" from "your token is bad" (401). +var ErrNotReady = errors.New("jwt validator: initial JWKS fetch not yet complete") + +// clockLeeway is the tolerance applied when validating the "exp" claim. A few +// seconds may elapse between the SPA minting the token and the request reaching +// the key-manager through nginx; without leeway, small clock drift causes +// spurious "token expired" errors. 30s is generous but still provides real +// expiry protection (Keycloak's default access-token lifetime is 5 minutes). +const clockLeeway = 30 * time.Second + +// jwk is a single JSON Web Key from Keycloak's JWKS endpoint. +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` +} + +// jwks is the JSON Web Key Set returned by Keycloak. +type jwks struct { + Keys []jwk `json:"keys"` +} + +// JWTValidator validates bearer tokens minted by Keycloak against the realm's +// JWKS. Under Model B (SPA-managed Keycloak) there is no gateway JWT +// enforcement, so the key-manager verifies the token itself: RSA signature, +// expiry (with clockLeeway), and issuer. +// +// The initial JWKS fetch runs asynchronously in a background goroutine started +// by NewJWTValidator so process startup does not block on Keycloak being +// reachable (avoids CrashLoopBackOff when Keycloak is slow to come up). While +// ready is false, ValidateToken returns ErrNotReady, which the auth middleware +// surfaces as 503 Service Unavailable — distinct from a bad token (401). +type JWTValidator struct { + logger *slog.Logger + keycloakURL string + // issuerURL validates the `iss` claim. It defaults to keycloakURL but is + // overridden with SetIssuerURL when the external Keycloak URL (embedded in + // tokens as `iss`) differs from the internal cluster URL used for JWKS + // fetching. + issuerURL string + realm string + publicKeys map[string]*rsa.PublicKey + keysMu sync.RWMutex + lastFetch time.Time + // ready flips to true once the first JWKS fetch succeeds. It is atomic + // because the writer runs on the background init goroutine while readers + // run on every request-handling goroutine. + ready atomic.Bool + stopCh chan struct{} + doneCh chan struct{} + stopOnce sync.Once +} + +// NewJWTValidator creates a validator and returns immediately; the initial +// JWKS fetch runs on a background goroutine. It first runs retryMaxAttempts +// active attempts with exponential backoff, then falls back to a +// slowPollInterval cadence and keeps trying indefinitely. Ready() flips to true +// the moment any fetch succeeds. +func NewJWTValidator(keycloakURL, realm string, logger *slog.Logger) *JWTValidator { + if logger == nil { + logger = slog.Default() + } + cleanURL := strings.TrimSuffix(keycloakURL, "/") + v := &JWTValidator{ + logger: logger, + keycloakURL: cleanURL, + issuerURL: cleanURL, // default; override with SetIssuerURL if needed + realm: realm, + publicKeys: make(map[string]*rsa.PublicKey), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + + go v.initLoop() + + logger.Info("JWT validator created; initial JWKS fetch running in background", + "keycloakURL", cleanURL, "realm", realm) + return v +} + +// SetIssuerURL overrides the URL used to validate the token's `iss` claim. Use +// it when the external Keycloak URL (written into tokens as `iss`) differs from +// the internal cluster URL used for JWKS fetching. An empty string is a no-op. +func (v *JWTValidator) SetIssuerURL(url string) { + if url == "" { + return + } + v.issuerURL = strings.TrimSuffix(url, "/") +} + +// initLoop runs the initial JWKS fetch with exponential backoff, then falls +// back to a slow poll if all active attempts fail. It exits as soon as any +// fetch succeeds or when Stop() is called. +func (v *JWTValidator) initLoop() { + defer close(v.doneCh) + + backoff := retryInitialBackoff + for attempt := 1; attempt <= retryMaxAttempts; attempt++ { + if v.stopped() { + return + } + if err := v.fetchPublicKeys(); err == nil { + v.ready.Store(true) + v.logger.Info("JWT validator ready", "attempt", attempt) + return + } else { + v.logger.Warn("failed to fetch Keycloak public keys, retrying", + "attempt", attempt, "maxRetries", retryMaxAttempts, "backoff", backoff, "error", err, + "hint", "verify LLM_KEYCLOAK_URL is correct — Keycloak 17+ does not use /auth as a context root") + } + if attempt < retryMaxAttempts { + retryDelay(backoff) + backoff *= 2 + } + } + + // Active budget exhausted: switch to a steady slow poll so the validator + // still comes online if Keycloak eventually recovers, without a tight retry + // storm in the logs. + v.logger.Warn("active retry budget exhausted; switching to slow poll", "interval", slowPollInterval) + for { + select { + case <-v.stopCh: + return + case <-time.After(slowPollInterval): + } + if err := v.fetchPublicKeys(); err == nil { + v.ready.Store(true) + v.logger.Info("JWT validator ready (slow poll)") + return + } else { + v.logger.Warn("slow poll JWKS fetch failed", "error", err) + } + } +} + +func (v *JWTValidator) stopped() bool { + select { + case <-v.stopCh: + return true + default: + return false + } +} + +// Ready reports whether the initial JWKS fetch has succeeded. +func (v *JWTValidator) Ready() bool { + return v.ready.Load() +} + +// Stop signals the background init goroutine to exit and waits for it. Safe to +// call multiple times, including concurrently. +func (v *JWTValidator) Stop() { + v.stopOnce.Do(func() { close(v.stopCh) }) + <-v.doneCh +} + +// ValidateToken verifies a bearer token's RSA signature, expiry (with +// clockLeeway) and issuer, returning the verified claims. It returns +// ErrNotReady if the initial JWKS fetch has not yet completed; the caller +// should surface that as 503 Service Unavailable. +func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{}, error) { + if !v.ready.Load() { + return nil, ErrNotReady + } + + if time.Since(v.lastFetch) > time.Hour { + if err := v.fetchPublicKeys(); err != nil { + v.logger.Error("failed to refresh public keys", "error", err) + } + } + + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing kid in token header") + } + + v.keysMu.RLock() + publicKey, exists := v.publicKeys[kid] + v.keysMu.RUnlock() + if exists { + return publicKey, nil + } + + // Key not cached — Keycloak may have rotated keys. Try a one-shot refresh. + if refreshErr := v.fetchPublicKeys(); refreshErr != nil { + return nil, fmt.Errorf("unknown key ID %s and key refresh failed: %w", kid, refreshErr) + } + v.keysMu.RLock() + publicKey, exists = v.publicKeys[kid] + v.keysMu.RUnlock() + if !exists { + return nil, fmt.Errorf("unknown key ID: %s (not found after key refresh)", kid) + } + return publicKey, nil + }, jwt.WithLeeway(clockLeeway)) + if err != nil { + return nil, fmt.Errorf("token validation failed: %w", err) + } + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + // Expiry is already enforced by ParseWithClaims (with clockLeeway); only the + // issuer needs a manual check. `aud`/`azp` are intentionally not checked, to + // match the reference webapi validation. + expectedIssuer := fmt.Sprintf("%s/realms/%s", v.issuerURL, v.realm) + if iss, _ := claims["iss"].(string); iss != expectedIssuer { + return nil, fmt.Errorf("invalid issuer: expected %s, got %s", expectedIssuer, iss) + } + + return claims, nil +} + +func (v *JWTValidator) fetchPublicKeys() error { + certsURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", v.keycloakURL, v.realm) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, certsURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch keys: %w", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + v.logger.Error("failed to close response body", "error", cerr) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch keys: status %d", resp.StatusCode) + } + + var set jwks + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return fmt.Errorf("failed to decode JWKS: %w", err) + } + + keys := make(map[string]*rsa.PublicKey) + for _, k := range set.Keys { + if k.Kty != "RSA" { + continue + } + publicKey, err := parseRSAPublicKey(k) + if err != nil { + v.logger.Error("failed to parse RSA public key", "kid", k.Kid, "error", err) + continue + } + keys[k.Kid] = publicKey + } + + if len(keys) == 0 { + return fmt.Errorf("no valid RSA keys found") + } + + v.keysMu.Lock() + v.publicKeys = keys + v.lastFetch = time.Now() + v.keysMu.Unlock() + + v.logger.Info("Keycloak public keys refreshed", "count", len(keys)) + return nil +} + +// parseRSAPublicKey rebuilds an RSA public key from a JWK's base64url modulus +// (n) and exponent (e). +func parseRSAPublicKey(k jwk) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, fmt.Errorf("failed to decode N: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, fmt.Errorf("failed to decode E: %w", err) + } + + n := new(big.Int).SetBytes(nBytes) + e := 0 + for _, b := range eBytes { + e = e*256 + int(b) + } + return &rsa.PublicKey{N: n, E: e}, nil +} diff --git a/key-manager/internal/api/middleware.go b/key-manager/internal/api/middleware.go index a123c97..965caec 100644 --- a/key-manager/internal/api/middleware.go +++ b/key-manager/internal/api/middleware.go @@ -2,8 +2,7 @@ package api import ( "context" - "encoding/base64" - "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -32,36 +31,32 @@ func UserFromContext(ctx context.Context) (*UserInfo, bool) { type AuthConfig struct { // GroupsClaim is the JWT claim containing groups (default: "groups"). GroupsClaim string - // CookiePrefix is the prefix for the IdToken cookie (default: "IdToken"). - // The middleware accepts any cookie whose name starts with this prefix. - CookiePrefix string - // DevMode disables token extraction and injects DevIdentity into every + // Validator verifies bearer tokens against Keycloak's JWKS (signature, + // expiry, issuer). Required unless DevMode is true. + Validator *JWTValidator + // DevMode disables token handling and injects DevIdentity into every // request. It exists so the UI can run on a local cluster that has no - // Keycloak or gateway OIDC layer in front of it. It is off by default and - // must never be enabled in a real deployment. + // Keycloak in front of it. It is off by default and must never be enabled + // in a real deployment. DevMode bool // DevIdentity is the user injected into the request context when DevMode // is on. Ignored when DevMode is false. DevIdentity UserInfo } -// AuthMiddleware returns HTTP middleware that extracts user identity from JWT. -// It does NOT validate signatures - Envoy Gateway handles that upstream. -// Token sources (in priority order): -// 1. Authorization: Bearer header -// 2. Cookie with name matching CookiePrefix +// AuthMiddleware returns HTTP middleware that authenticates requests using a +// Keycloak bearer token (Model B: SPA-managed Keycloak). It extracts the token +// from the Authorization header, verifies it against the realm's JWKS via +// cfg.Validator, and injects the resulting identity into the request context. func AuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { if cfg.GroupsClaim == "" { cfg.GroupsClaim = "groups" } - if cfg.CookiePrefix == "" { - cfg.CookiePrefix = "IdToken" - } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Dev mode: skip token handling entirely and inject a fixed - // identity so the UI works without an OIDC provider in front. + // Dev mode: skip token handling entirely and inject a fixed identity + // so the UI works without an OIDC provider in front. if cfg.DevMode { devUser := cfg.DevIdentity ctx := context.WithValue(r.Context(), userContextKey, &devUser) @@ -69,78 +64,68 @@ func AuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { return } - token, err := extractToken(r, cfg.CookiePrefix) + if cfg.Validator == nil { + http.Error(w, "auth not configured", http.StatusInternalServerError) + return + } + + token, err := extractBearerToken(r) if err != nil { http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized) return } - user, err := parseJWT(token, cfg.GroupsClaim) + claims, err := cfg.Validator.ValidateToken(token) if err != nil { + // JWKS not fetched yet: transient, tell the client to retry. + if errors.Is(err, ErrNotReady) { + w.Header().Set("Retry-After", "5") + http.Error(w, "auth not ready: "+err.Error(), http.StatusServiceUnavailable) + return + } http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized) return } + user := userInfoFromClaims(claims, cfg.GroupsClaim) ctx := context.WithValue(r.Context(), userContextKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } } -// extractToken retrieves the raw JWT string from the request. -// Checks Authorization: Bearer header first, then cookies matching the prefix. -func extractToken(r *http.Request, cookiePrefix string) (string, error) { - // Check Authorization header first. +// extractBearerToken returns the raw JWT from the Authorization: Bearer header. +func extractBearerToken(r *http.Request) (string, error) { authHeader := r.Header.Get("Authorization") - if authHeader != "" { - if !strings.HasPrefix(authHeader, "Bearer ") { - return "", fmt.Errorf("authorization header must use Bearer scheme") - } - token := strings.TrimPrefix(authHeader, "Bearer ") - if token == "" { - return "", fmt.Errorf("bearer token is empty") - } - return token, nil - } - - // Fall back to cookie matching the prefix. - for _, cookie := range r.Cookies() { - if strings.HasPrefix(cookie.Name, cookiePrefix) { - return cookie.Value, nil - } + if authHeader == "" { + return "", fmt.Errorf("no Authorization header") } - - return "", fmt.Errorf("no token found in Authorization header or cookie") -} - -// parseJWT decodes a JWT payload (without signature verification) and extracts user identity. -func parseJWT(token, groupsClaim string) (*UserInfo, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts)) + if !strings.HasPrefix(authHeader, "Bearer ") { + return "", fmt.Errorf("authorization header must use Bearer scheme") } - - payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid JWT payload encoding: %w", err) - } - - var claims map[string]interface{} - if err := json.Unmarshal(payloadBytes, &claims); err != nil { - return nil, fmt.Errorf("invalid JWT payload JSON: %w", err) + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == "" { + return "", fmt.Errorf("bearer token is empty") } + return token, nil +} +// userInfoFromClaims builds a UserInfo from a set of verified JWT claims. +func userInfoFromClaims(claims map[string]interface{}, groupsClaim string) *UserInfo { username, _ := claims["preferred_username"].(string) + if username == "" { + // Fall back to the subject when preferred_username is absent. + username, _ = claims["sub"].(string) + } name, _ := claims["name"].(string) email, _ := claims["email"].(string) - groups := extractGroups(claims, groupsClaim) return &UserInfo{ Username: username, Name: name, Email: email, - Groups: groups, - }, nil + Groups: extractGroups(claims, groupsClaim), + } } // extractGroups reads the groups claim from JWT claims. @@ -161,6 +146,8 @@ func extractGroups(claims map[string]interface{}, claimName string) []string { } } return groups + case []string: + return v case string: return []string{v} default: diff --git a/key-manager/internal/api/middleware_test.go b/key-manager/internal/api/middleware_test.go index 79161fb..577c0dd 100644 --- a/key-manager/internal/api/middleware_test.go +++ b/key-manager/internal/api/middleware_test.go @@ -1,31 +1,69 @@ package api import ( - "encoding/base64" + "crypto/rand" + "crypto/rsa" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + testRealm = "nebari" + testIssuer = "https://keycloak.example.com" + testKID = "test-key-1" ) -// makeTestJWT creates a test JWT with the given claims by base64url-encoding a JSON payload. -// No actual signing - Envoy Gateway handles verification in production. -func makeTestJWT(claims map[string]interface{}) string { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload, _ := json.Marshal(claims) - payloadB64 := base64.RawURLEncoding.EncodeToString(payload) - sig := base64.RawURLEncoding.EncodeToString([]byte("fake-signature")) - return header + "." + payloadB64 + "." + sig +// newTestValidator returns a JWTValidator preloaded with pub under testKID and +// marked ready, so tests validate signed tokens without any network fetch. +func newTestValidator(t *testing.T, pub *rsa.PublicKey) *JWTValidator { + t.Helper() + v := &JWTValidator{ + logger: slog.Default(), + keycloakURL: testIssuer, + issuerURL: testIssuer, + realm: testRealm, + publicKeys: map[string]*rsa.PublicKey{testKID: pub}, + lastFetch: time.Now(), + } + v.ready.Store(true) + return v } -func defaultConfig() AuthConfig { - return AuthConfig{ - GroupsClaim: "groups", - CookiePrefix: "IdToken", +// signToken signs claims as an RS256 JWT with testKID. iss/exp are filled with +// valid defaults unless already present. +func signToken(t *testing.T, priv *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + if _, ok := claims["iss"]; !ok { + claims["iss"] = testIssuer + "/realms/" + testRealm } + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(5 * time.Minute).Unix() + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = testKID + s, err := tok.SignedString(priv) + if err != nil { + t.Fatalf("signing token: %v", err) + } + return s } -// handlerThatChecksUser is a test handler that writes the extracted username/groups to the response. +func genKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating key: %v", err) + } + return key +} + +// handlerThatChecksUser writes the extracted username/groups to the response. func handlerThatChecksUser(t *testing.T) http.Handler { t.Helper() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -43,6 +81,10 @@ func handlerThatChecksUser(t *testing.T) http.Handler { } func TestAuthMiddleware(t *testing.T) { + priv := genKey(t) + otherPriv := genKey(t) + validator := newTestValidator(t, &priv.PublicKey) + tests := []struct { name string cfg AuthConfig @@ -52,10 +94,10 @@ func TestAuthMiddleware(t *testing.T) { wantGroups []string }{ { - name: "bearer JWT in Authorization header extracts username and groups", - cfg: defaultConfig(), + name: "valid bearer token extracts username and groups", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ + token := signToken(t, priv, jwt.MapClaims{ "preferred_username": "alice", "groups": []string{"admins", "users"}, }) @@ -66,121 +108,65 @@ func TestAuthMiddleware(t *testing.T) { wantGroups: []string{"admins", "users"}, }, { - name: "JWT in IdToken cookie extracts username and groups", - cfg: defaultConfig(), - setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "bob", - "groups": []string{"users"}, - }) - r.AddCookie(&http.Cookie{Name: "IdToken-somedeployment", Value: token}) - }, - wantStatus: http.StatusOK, - wantUsername: "bob", - wantGroups: []string{"users"}, + name: "no token returns 401", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, + setupRequest: func(r *http.Request) {}, + wantStatus: http.StatusUnauthorized, }, { - name: "no token returns 401", - cfg: defaultConfig(), + name: "Authorization header without Bearer prefix returns 401", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, setupRequest: func(r *http.Request) { - // no cookie, no Authorization header + r.Header.Set("Authorization", "some-token") }, wantStatus: http.StatusUnauthorized, }, { - name: "invalid JWT without 3 parts returns 401", - cfg: defaultConfig(), + name: "garbage token returns 401", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, setupRequest: func(r *http.Request) { r.Header.Set("Authorization", "Bearer notajwt") }, wantStatus: http.StatusUnauthorized, }, { - name: "malformed base64 in payload returns 401", - cfg: defaultConfig(), + name: "token signed by an unknown key returns 401", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, setupRequest: func(r *http.Request) { - // header.invalid-base64!.sig - r.Header.Set("Authorization", "Bearer aGVhZGVy.!!!notbase64!!!.c2ln") - }, - wantStatus: http.StatusUnauthorized, - }, - { - name: "groups claim as string array works", - cfg: defaultConfig(), - setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "carol", - "groups": []string{"eng", "ml"}, - }) + token := signToken(t, otherPriv, jwt.MapClaims{"preferred_username": "mallory"}) r.Header.Set("Authorization", "Bearer "+token) }, - wantStatus: http.StatusOK, - wantUsername: "carol", - wantGroups: []string{"eng", "ml"}, + wantStatus: http.StatusUnauthorized, }, { - name: "groups claim as single string is converted to []string", - cfg: defaultConfig(), + name: "token with wrong issuer returns 401", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "dave", - "groups": "solo-group", + token := signToken(t, priv, jwt.MapClaims{ + "preferred_username": "alice", + "iss": "https://evil.example.com/realms/nebari", }) r.Header.Set("Authorization", "Bearer "+token) }, - wantStatus: http.StatusOK, - wantUsername: "dave", - wantGroups: []string{"solo-group"}, + wantStatus: http.StatusUnauthorized, }, { - name: "missing groups claim results in empty groups", - cfg: defaultConfig(), + name: "expired token returns 401", + cfg: AuthConfig{GroupsClaim: "groups", Validator: validator}, setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "eve", + token := signToken(t, priv, jwt.MapClaims{ + "preferred_username": "alice", + "exp": time.Now().Add(-time.Hour).Unix(), }) r.Header.Set("Authorization", "Bearer "+token) }, - wantStatus: http.StatusOK, - wantUsername: "eve", - wantGroups: []string{}, - }, - { - name: "Authorization header without Bearer prefix returns 401", - cfg: defaultConfig(), - setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "frank", - }) - r.Header.Set("Authorization", token) - }, wantStatus: http.StatusUnauthorized, }, - { - name: "custom cookie prefix is respected", - cfg: AuthConfig{ - GroupsClaim: "groups", - CookiePrefix: "OauthToken", - }, - setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "grace", - "groups": []string{"devs"}, - }) - r.AddCookie(&http.Cookie{Name: "OauthToken-prod", Value: token}) - }, - wantStatus: http.StatusOK, - wantUsername: "grace", - wantGroups: []string{"devs"}, - }, { name: "custom groups claim name is respected", - cfg: AuthConfig{ - GroupsClaim: "cognito:groups", - CookiePrefix: "IdToken", - }, + cfg: AuthConfig{GroupsClaim: "cognito:groups", Validator: validator}, setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ + token := signToken(t, priv, jwt.MapClaims{ "preferred_username": "henry", "cognito:groups": []string{"cognito-admins"}, }) @@ -199,14 +185,13 @@ func TestAuthMiddleware(t *testing.T) { if tc.wantStatus == http.StatusOK { handler = middleware(handlerThatChecksUser(t)) } else { - // For error cases, inner handler should not be called. handler = middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("inner handler should not be called when auth fails") w.WriteHeader(http.StatusOK) })) } - req := httptest.NewRequest(http.MethodGet, "/", nil) + req := httptest.NewRequest(http.MethodGet, "/api/keys", nil) tc.setupRequest(req) rec := httptest.NewRecorder() @@ -217,24 +202,19 @@ func TestAuthMiddleware(t *testing.T) { } if tc.wantStatus == http.StatusOK { - gotUsername := rec.Header().Get("X-Username") - if gotUsername != tc.wantUsername { - t.Errorf("username: got %q, want %q", gotUsername, tc.wantUsername) + if got := rec.Header().Get("X-Username"); got != tc.wantUsername { + t.Errorf("username: got %q, want %q", got, tc.wantUsername) } - var gotGroups []string - groupsJSON := rec.Header().Get("X-Groups") - if err := json.Unmarshal([]byte(groupsJSON), &gotGroups); err != nil { - t.Fatalf("failed to parse groups header %q: %v", groupsJSON, err) + if err := json.Unmarshal([]byte(rec.Header().Get("X-Groups")), &gotGroups); err != nil { + t.Fatalf("failed to parse groups header %q: %v", rec.Header().Get("X-Groups"), err) } if len(gotGroups) != len(tc.wantGroups) { - t.Errorf("groups length: got %d, want %d (got %v, want %v)", - len(gotGroups), len(tc.wantGroups), gotGroups, tc.wantGroups) - } else { - for i := range gotGroups { - if gotGroups[i] != tc.wantGroups[i] { - t.Errorf("groups[%d]: got %q, want %q", i, gotGroups[i], tc.wantGroups[i]) - } + t.Fatalf("groups: got %v, want %v", gotGroups, tc.wantGroups) + } + for i := range gotGroups { + if gotGroups[i] != tc.wantGroups[i] { + t.Errorf("groups[%d]: got %q, want %q", i, gotGroups[i], tc.wantGroups[i]) } } } @@ -242,6 +222,43 @@ func TestAuthMiddleware(t *testing.T) { } } +// TestAuthMiddlewareNotReady checks that requests during JWKS warmup get a 503 +// with Retry-After, not a 401, so clients can distinguish "auth not online yet". +func TestAuthMiddlewareNotReady(t *testing.T) { + priv := genKey(t) + v := newTestValidator(t, &priv.PublicKey) + v.ready.Store(false) + + handler := AuthMiddleware(AuthConfig{Validator: v})(handlerThatChecksUser(t)) + req := httptest.NewRequest(http.MethodGet, "/api/keys", nil) + req.Header.Set("Authorization", "Bearer "+signToken(t, priv, jwt.MapClaims{"preferred_username": "alice"})) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status: got %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("expected Retry-After header on 503") + } +} + +// TestAuthMiddlewareMisconfigured checks that a non-dev config without a +// validator fails closed with 500 rather than admitting the request. +func TestAuthMiddlewareMisconfigured(t *testing.T) { + handler := AuthMiddleware(AuthConfig{})(handlerThatChecksUser(t)) + req := httptest.NewRequest(http.MethodGet, "/api/keys", nil) + req.Header.Set("Authorization", "Bearer whatever") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status: got %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + func TestAuthMiddlewareDevMode(t *testing.T) { devIdentity := UserInfo{ Username: "dev", @@ -265,11 +282,7 @@ func TestAuthMiddlewareDevMode(t *testing.T) { { name: "a supplied bearer token is ignored in favor of the dev identity", setupRequest: func(r *http.Request) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "alice", - "groups": []string{"admins"}, - }) - r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Authorization", "Bearer some-token") }, wantUsername: "dev", wantGroups: []string{"llm"}, @@ -279,10 +292,9 @@ func TestAuthMiddlewareDevMode(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cfg := AuthConfig{ - GroupsClaim: "groups", - CookiePrefix: "IdToken", - DevMode: true, - DevIdentity: devIdentity, + GroupsClaim: "groups", + DevMode: true, + DevIdentity: devIdentity, } handler := AuthMiddleware(cfg)(handlerThatChecksUser(t)) @@ -314,43 +326,65 @@ func TestAuthMiddlewareDevMode(t *testing.T) { } } -func TestParseJWTExtractsNameAndEmail(t *testing.T) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "chuck", - "name": "Chuck Norris", - "email": "chuck@example.com", - "groups": []string{"llm"}, +func TestUserInfoFromClaims(t *testing.T) { + t.Run("extracts name, email, username, groups", func(t *testing.T) { + u := userInfoFromClaims(map[string]interface{}{ + "preferred_username": "chuck", + "name": "Chuck Norris", + "email": "chuck@example.com", + "groups": []interface{}{"llm"}, + }, "groups") + if u.Username != "chuck" { + t.Errorf("username: got %q, want %q", u.Username, "chuck") + } + if u.Name != "Chuck Norris" { + t.Errorf("name: got %q, want %q", u.Name, "Chuck Norris") + } + if u.Email != "chuck@example.com" { + t.Errorf("email: got %q, want %q", u.Email, "chuck@example.com") + } + if len(u.Groups) != 1 || u.Groups[0] != "llm" { + t.Errorf("groups: got %v, want [llm]", u.Groups) + } }) - user, err := parseJWT(token, "groups") - if err != nil { - t.Fatalf("parseJWT returned error: %v", err) - } - if user.Username != "chuck" { - t.Errorf("username: got %q, want %q", user.Username, "chuck") - } - if user.Name != "Chuck Norris" { - t.Errorf("name: got %q, want %q", user.Name, "Chuck Norris") - } - if user.Email != "chuck@example.com" { - t.Errorf("email: got %q, want %q", user.Email, "chuck@example.com") - } -} - -func TestParseJWTMissingNameAndEmailAreEmpty(t *testing.T) { - token := makeTestJWT(map[string]interface{}{ - "preferred_username": "eve", + t.Run("falls back to sub when preferred_username absent", func(t *testing.T) { + u := userInfoFromClaims(map[string]interface{}{"sub": "abc-123"}, "groups") + if u.Username != "abc-123" { + t.Errorf("username: got %q, want %q", u.Username, "abc-123") + } + if u.Name != "" || u.Email != "" { + t.Errorf("expected empty name/email, got %q/%q", u.Name, u.Email) + } + if len(u.Groups) != 0 { + t.Errorf("expected empty groups, got %v", u.Groups) + } }) +} - user, err := parseJWT(token, "groups") - if err != nil { - t.Fatalf("parseJWT returned error: %v", err) - } - if user.Name != "" { - t.Errorf("name: got %q, want empty", user.Name) +func TestExtractGroups(t *testing.T) { + tests := []struct { + name string + claims map[string]interface{} + claim string + want []string + }{ + {"interface array", map[string]interface{}{"groups": []interface{}{"a", "b"}}, "groups", []string{"a", "b"}}, + {"single string", map[string]interface{}{"groups": "solo"}, "groups", []string{"solo"}}, + {"missing", map[string]interface{}{}, "groups", []string{}}, } - if user.Email != "" { - t.Errorf("email: got %q, want empty", user.Email) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractGroups(tc.claims, tc.claim) + if len(got) != len(tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("[%d]: got %q, want %q", i, got[i], tc.want[i]) + } + } + }) } } diff --git a/key-manager/internal/ui/embed.go b/key-manager/internal/ui/embed.go deleted file mode 100644 index 901d5c5..0000000 --- a/key-manager/internal/ui/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package ui - -import "embed" - -//go:embed static/* -var StaticFiles embed.FS diff --git a/key-manager/internal/ui/static/app.js b/key-manager/internal/ui/static/app.js deleted file mode 100644 index 3196898..0000000 --- a/key-manager/internal/ui/static/app.js +++ /dev/null @@ -1,422 +0,0 @@ -'use strict'; - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- -let allModels = []; - -// --------------------------------------------------------------------------- -// DOM helpers -// --------------------------------------------------------------------------- -function $(id) { return document.getElementById(id); } - -function showError(message) { - const banner = $('error-banner'); - banner.textContent = message; - banner.classList.remove('hidden'); -} - -function clearError() { - $('error-banner').classList.add('hidden'); -} - -function showFieldError(id, message) { - const el = $(id); - el.textContent = message; - el.classList.remove('hidden'); -} - -function clearFieldError(id) { - $(id).classList.add('hidden'); -} - -function escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -// --------------------------------------------------------------------------- -// Dialog controller -// --------------------------------------------------------------------------- -function openDialog(id) { - $(id).classList.remove('hidden'); -} - -function closeDialog(id) { - $(id).classList.add('hidden'); -} - -// Close buttons (× and Cancel) carry data-close="". -document.querySelectorAll('[data-close]').forEach((el) => { - el.addEventListener('click', () => closeDialog(el.getAttribute('data-close'))); -}); - -// Close on overlay click and Escape. -document.querySelectorAll('.dialog-overlay').forEach((overlay) => { - overlay.addEventListener('click', (e) => { - if (e.target === overlay) overlay.classList.add('hidden'); - }); -}); - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - document.querySelectorAll('.dialog-overlay:not(.hidden)').forEach((o) => o.classList.add('hidden')); - closeAllMenus(); - } -}); - -// --------------------------------------------------------------------------- -// API helpers -// --------------------------------------------------------------------------- -async function apiFetch(method, path, body) { - const opts = { - method, - headers: { 'Content-Type': 'application/json' }, - }; - if (body !== undefined) { - opts.body = JSON.stringify(body); - } - const resp = await fetch(path, opts); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`${method} ${path} failed (${resp.status}): ${text.trim()}`); - } - if (resp.status === 204) return null; - return resp.json(); -} - -// --------------------------------------------------------------------------- -// Models (populate the Create dialog's select only) -// --------------------------------------------------------------------------- -async function loadModels() { - try { - const data = await apiFetch('GET', '/api/models'); - allModels = data.models || []; - populateModelSelect(allModels); - } catch (err) { - showError('Failed to load models: ' + err.message); - } -} - -function populateModelSelect(models) { - const sel = $('model-select'); - while (sel.options.length > 1) sel.remove(1); - - for (const m of models) { - const name = m.Name || m.name || ''; - const ns = m.Namespace || m.namespace || ''; - const opt = document.createElement('option'); - opt.value = name; - opt.textContent = ns ? `${ns}/${name}` : name; - sel.appendChild(opt); - } -} - -// --------------------------------------------------------------------------- -// Keys table -// --------------------------------------------------------------------------- -async function loadKeys() { - $('keys-loading').classList.remove('hidden'); - $('keys-container').classList.add('hidden'); - try { - const data = await apiFetch('GET', '/api/keys'); - renderKeys(data.keys || []); - } catch (err) { - $('keys-loading').classList.add('hidden'); - $('keys-container').classList.remove('hidden'); - $('keys-container').innerHTML = '

Failed to load keys: ' + escapeHtml(err.message) + '

'; - } -} - -function renderKeys(keys) { - $('keys-loading').classList.add('hidden'); - const container = $('keys-container'); - container.classList.remove('hidden'); - - if (keys.length === 0) { - container.innerHTML = '

No API keys yet.

'; - return; - } - - const table = document.createElement('table'); - table.className = 'keys-table'; - table.innerHTML = ` -
Name / DescriptionClient IDModelCreatedAction
${escapeHtml(desc || '—')}${escapeHtml(clientId)}${escapeHtml(modelName)}${escapeHtml(created)}
- - - Name / Description - Client ID - Model - Created - Action +
+
+

My API Keys

+ +
+ + {isLoading ? ( +

Loading keys…

+ ) : isError ? ( +

+ Failed to load keys: {error instanceof Error ? error.message : "unknown error"} +

+ ) : !keys || keys.length === 0 ? ( +

No API keys yet.

+ ) : ( +
+ + + Name / Description + Client ID + Model + Created + Action + + + + {keys.map((key) => ( + + {key.description || "—"} + {key.clientId} + {key.modelName} + {formatDate(key.createdAt)} + + + - - - {keys.map((key) => ( - - {key.description || "—"} - {key.clientId} - {key.modelName} - - {formatDate(key.createdAt)} - - - - - - ))} - -
- )} - - + ))} + + + )} + ); } diff --git a/frontend/src/components/Topbar/Topbar.tsx b/frontend/src/components/Topbar/Topbar.tsx index a051711..ff4e375 100644 --- a/frontend/src/components/Topbar/Topbar.tsx +++ b/frontend/src/components/Topbar/Topbar.tsx @@ -24,7 +24,7 @@ export function Topbar() { const displayName = user?.name || user?.email || "Account"; return ( -
+
Nebari Nebari diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx index 889ca32..8677ca6 100644 --- a/frontend/src/components/ui/table.tsx +++ b/frontend/src/components/ui/table.tsx @@ -1,21 +1,72 @@ +// biome-ignore-all lint/a11y/noNoninteractiveTabindex: table scroll containers need keyboard access when content overflows. import type * as React from "react"; - import { cn } from "@/lib/utils"; -function Table({ className, ...props }: React.ComponentProps<"table">) { +type TableProps = React.ComponentProps<"table"> & { + /** Accessible name for the keyboard-focusable horizontal scroll container. */ + scrollContainerLabel?: string; + /** Additional classes for the horizontal scroll container. */ + scrollContainerClassName?: string; + /** Props forwarded to the horizontal scroll container. */ + scrollContainerProps?: Omit, "children">; +}; + +type TableHeadProps = Omit, "onClick" | "onKeyDown"> & { + /** Makes the header sortable by rendering its contents in a real button. */ + onClick?: React.MouseEventHandler; + onKeyDown?: React.KeyboardEventHandler; +}; + +/** Responsive table frame with Nebari border, radius, and surface styling. */ +function Table({ + className, + scrollContainerClassName, + scrollContainerLabel, + scrollContainerProps, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + ...props +}: TableProps) { + const { + className: scrollContainerPropsClassName, + "aria-label": scrollContainerAriaLabel, + "aria-labelledby": scrollContainerAriaLabelledBy, + tabIndex: scrollContainerTabIndex, + ...resolvedScrollContainerProps + } = scrollContainerProps ?? {}; + const resolvedScrollContainerLabel = + scrollContainerAriaLabel ?? + scrollContainerLabel ?? + (ariaLabel === undefined ? "Table scroll area" : `${ariaLabel} scroll area`); + return ( -
+
- + ); } function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { - return ; + return ; } function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { @@ -32,7 +83,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { return ( tr]:last:border-b-0", className)} + className={cn("border-t border-border bg-card font-medium [&>tr]:last:border-b-0", className)} {...props} /> ); @@ -42,22 +93,53 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( ); } -function TableHead({ className, ...props }: React.ComponentProps<"th">) { +function TableHead({ + className, + children, + onClick, + onKeyDown, + tabIndex, + ...props +}: TableHeadProps) { + const isInteractive = onClick != null; + const ariaSort = props["aria-sort"] ?? (isInteractive ? "none" : undefined); + return ( ); } @@ -65,7 +147,10 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { return (
+ > + {isInteractive ? ( + + ) : ( + children + )} + ); @@ -75,10 +160,11 @@ function TableCaption({ className, ...props }: React.ComponentProps<"caption">) return (
); } +export type { TableHeadProps, TableProps }; export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 03196ce..4ba3abe 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -78,6 +78,9 @@ --ease-emphasized: cubic-bezier(0.2, 0, 0, 1); /* App-specific tokens (not part of the Nebari theme) */ + --body-background: #ffffff; + --header-background: #f8f8f8; + --pill-category-fg: #475467; --text-secondary: #4e596a; @@ -149,6 +152,9 @@ --sidebar-ring: oklch(69.98% 0.1926 311.48); /* App-specific tokens (not part of the Nebari theme) */ + --body-background: #262628; + --header-background: #353538; + --pill-category-fg: #ffffff; --text-secondary: #d0d5dd; @@ -213,6 +219,9 @@ --color-foreground: var(--foreground); --color-scrim: var(--scrim); + --color-body-background: var(--body-background); + --color-header-background: var(--header-background); + --color-card: var(--card); --color-card-foreground: var(--card-foreground); @@ -283,7 +292,7 @@ } body { - @apply bg-background text-foreground; + @apply bg-body-background text-foreground; } html, From 23d541b7ca0f4af0b1de93dc58a2c2c53b741302 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 8 Jul 2026 10:13:56 -0400 Subject: [PATCH 12/15] Address PR review: JWKS DoS/race fixes, azp pin, NebariApp guard - jwt_validator: rate-limit + de-duplicate (singleflight) unknown-kid JWKS refreshes so forged tokens can't drive a fetch storm on Keycloak; promote golang.org/x/sync to a direct dep. - jwt_validator: read lastFetch under keysMu (fixes data race flagged by go test -race). - jwt_validator/main.go/chart: opt-in azp pin (LLM_KEYCLOAK_SPA_CLIENT_ID / keyManager.keycloak.pinClientAudience) to reject tokens minted for other clients in the shared nebari realm; unset preserves iss-only. - key-manager-nebariapp: fail guard when nebariApp.enabled && !frontend.enabled, so the gateway can't target an unrendered -frontend Service. - Add jwt_validator_test covering the refresh rate-limit and azp pin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/key-manager-deployment.yaml | 8 + .../templates/key-manager-nebariapp.yaml | 3 + charts/nebari-llm-serving/values.yaml | 8 + key-manager/cmd/main.go | 10 +- key-manager/go.mod | 1 + key-manager/internal/api/jwt_validator.go | 105 ++++++++-- .../internal/api/jwt_validator_test.go | 180 ++++++++++++++++++ 7 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 key-manager/internal/api/jwt_validator_test.go diff --git a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml index 47f46eb..6fced9b 100644 --- a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml @@ -50,6 +50,14 @@ spec: value: {{ .Values.keyManager.keycloak.realm | default .Values.frontend.keycloak.realm | quote }} - name: LLM_KEYCLOAK_ISSUER_URL value: {{ .Values.keyManager.keycloak.issuerURL | default .Values.frontend.keycloak.url | quote }} + {{- if .Values.keyManager.keycloak.pinClientAudience }} + # Opt-in hardening: pin the token's `azp` to the SPA client ID so a + # token minted for another client in the (shared) nebari realm is + # rejected. Derived from the same client ID the SPA logs in with, so + # the two always agree. + - name: LLM_KEYCLOAK_SPA_CLIENT_ID + value: {{ .Values.frontend.keycloak.clientId | default (printf "%s-%s-key-manager-spa" (include "nebari-llm-serving.operatorNamespace" .) (include "nebari-llm-serving.fullname" .)) | quote }} + {{- end }} {{- end }} {{- if .Values.keyManager.auditInterval }} - name: LLM_AUDIT_INTERVAL diff --git a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml index af8c8b3..7a06f65 100644 --- a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml @@ -1,4 +1,7 @@ {{- if and .Values.keyManager.enabled .Values.keyManager.nebariApp.enabled }} +{{- if not .Values.frontend.enabled }} +{{- fail "keyManager.nebariApp.enabled=true requires frontend.enabled=true: the NebariApp (and its landing-page healthcheck on /) targets the -frontend Service, which is only rendered when frontend.enabled=true. For an API-only install, set keyManager.nebariApp.enabled=false." }} +{{- end }} apiVersion: reconcilers.nebari.dev/v1 kind: NebariApp metadata: diff --git a/charts/nebari-llm-serving/values.yaml b/charts/nebari-llm-serving/values.yaml index 1ddb059..84ccfb5 100644 --- a/charts/nebari-llm-serving/values.yaml +++ b/charts/nebari-llm-serving/values.yaml @@ -93,6 +93,14 @@ keyManager: # issuerURL - the URL embedded in tokens as `iss`; validated exactly. # Defaults to frontend.keycloak.url (the URL the browser talks to). issuerURL: "" + # pinClientAudience - when true, the key-manager additionally checks the + # token's `azp` (authorized party) claim against the SPA client ID, rejecting + # tokens minted for other clients in the realm. The nebari realm is shared + # across all Nebari apps, so leaving this false accepts any valid token in the + # realm (issuer-only validation). The client ID is derived from + # frontend.keycloak.clientId (or the operator convention), so it always + # matches the token the SPA actually presents. + pinClientAudience: false # nebariApp - the NebariApp CR that exposes the key-manager UI via the # nebari-operator (HTTPRoute, TLS cert, Keycloak client, landing-page tile). # Set enabled=false when installing standalone without a Nebari cluster. diff --git a/key-manager/cmd/main.go b/key-manager/cmd/main.go index 63e2f4f..020c094 100644 --- a/key-manager/cmd/main.go +++ b/key-manager/cmd/main.go @@ -42,6 +42,10 @@ func main() { keycloakURL := os.Getenv("LLM_KEYCLOAK_URL") keycloakRealm := getEnvOrDefault("LLM_KEYCLOAK_REALM", "nebari") keycloakIssuerURL := os.Getenv("LLM_KEYCLOAK_ISSUER_URL") + // Optional: pin the accepted token's `azp` to the SPA client ID. The nebari + // realm is shared, so leaving this unset accepts any client's token in the + // realm; set it to reject tokens minted for other clients. + keycloakSPAClientID := os.Getenv("LLM_KEYCLOAK_SPA_CLIENT_ID") auditInterval, err := time.ParseDuration(auditIntervalStr) if err != nil { @@ -134,8 +138,12 @@ func main() { } validator := api.NewJWTValidator(keycloakURL, keycloakRealm, logger) validator.SetIssuerURL(keycloakIssuerURL) + validator.SetExpectedClientID(keycloakSPAClientID) authConfig.Validator = validator - logger.Info("bearer-token auth enabled", "keycloakURL", keycloakURL, "realm", keycloakRealm) + if keycloakSPAClientID == "" { + logger.Warn("LLM_KEYCLOAK_SPA_CLIENT_ID not set: accepting tokens from any client in the realm (azp is not pinned)") + } + logger.Info("bearer-token auth enabled", "keycloakURL", keycloakURL, "realm", keycloakRealm, "azpPinned", keycloakSPAClientID != "") } authMW := api.AuthMiddleware(authConfig) diff --git a/key-manager/go.mod b/key-manager/go.mod index 9b39423..8ec1ab8 100644 --- a/key-manager/go.mod +++ b/key-manager/go.mod @@ -7,6 +7,7 @@ replace github.com/nebari-dev/nebari-llm-serving-pack/operator => ../operator require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/nebari-dev/nebari-llm-serving-pack/operator v0.0.0 + golang.org/x/sync v0.18.0 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 diff --git a/key-manager/internal/api/jwt_validator.go b/key-manager/internal/api/jwt_validator.go index 7fa0970..5149147 100644 --- a/key-manager/internal/api/jwt_validator.go +++ b/key-manager/internal/api/jwt_validator.go @@ -16,6 +16,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "golang.org/x/sync/singleflight" ) // retryMaxAttempts and retryInitialBackoff control the *active* JWKS fetch @@ -45,6 +46,21 @@ var ErrNotReady = errors.New("jwt validator: initial JWKS fetch not yet complete // expiry protection (Keycloak's default access-token lifetime is 5 minutes). const clockLeeway = 30 * time.Second +// unknownKIDCooldown is the minimum interval between JWKS refreshes triggered by +// a token bearing an unrecognized `kid`. It exists purely to cap outbound load +// on Keycloak: because the gateway runs enforceAtGateway:false and nginx +// forwards Authorization as-is, an unauthenticated caller can send forged tokens +// with arbitrary `kid`s — this code runs *before* signature verification — and +// each cache miss would otherwise fan out into a 10s HTTP GET to Keycloak's +// /certs. singleflight collapses concurrent misses into one in-flight fetch, and +// this cooldown bounds the sustained refresh rate so request volume can't drive +// a fetch storm. Legitimate key rotations are rare and tolerate a 30s delay. +const unknownKIDCooldown = 30 * time.Second + +// errKIDRefreshCooldown is returned when an unknown-`kid` refresh is suppressed +// because another refresh happened within unknownKIDCooldown. +var errKIDRefreshCooldown = errors.New("unknown-kid JWKS refresh suppressed by cooldown") + // jwk is a single JSON Web Key from Keycloak's JWKS endpoint. type jwk struct { Kty string `json:"kty"` @@ -76,11 +92,24 @@ type JWTValidator struct { // overridden with SetIssuerURL when the external Keycloak URL (embedded in // tokens as `iss`) differs from the internal cluster URL used for JWKS // fetching. - issuerURL string - realm string - publicKeys map[string]*rsa.PublicKey - keysMu sync.RWMutex - lastFetch time.Time + issuerURL string + realm string + // expectedClientID, when non-empty, pins the token's `azp` (authorized party) + // claim to a specific Keycloak client. The `nebari` realm is shared across all + // Nebari apps, so `iss` alone would accept a token minted for any client in + // the realm; pinning `azp` restricts acceptance to tokens obtained by the + // key-manager's own SPA client. Empty disables the check (see SetExpectedClientID). + expectedClientID string + publicKeys map[string]*rsa.PublicKey + keysMu sync.RWMutex + lastFetch time.Time + // refreshGroup collapses concurrent JWKS refreshes (hourly re-fetch and + // unknown-kid re-fetch) into a single in-flight outbound request. + refreshGroup singleflight.Group + // lastKIDRefresh is the unix-nano timestamp of the most recent unknown-kid + // refresh attempt, used to enforce unknownKIDCooldown. Atomic because it is + // read and written from every request-handling goroutine. + lastKIDRefresh atomic.Int64 // ready flips to true once the first JWKS fetch succeeds. It is atomic // because the writer runs on the background init goroutine while readers // run on every request-handling goroutine. @@ -127,6 +156,13 @@ func (v *JWTValidator) SetIssuerURL(url string) { v.issuerURL = strings.TrimSuffix(url, "/") } +// SetExpectedClientID pins the accepted token's `azp` claim to clientID. Use it +// to reject tokens minted for other clients in a shared Keycloak realm. An empty +// string is a no-op, leaving the `azp` check disabled (issuer-only validation). +func (v *JWTValidator) SetExpectedClientID(clientID string) { + v.expectedClientID = clientID +} + // initLoop runs the initial JWKS fetch with exponential backoff, then falls // back to a slow poll if all active attempts fail. It exits as soon as any // fetch succeeds or when Stop() is called. @@ -203,8 +239,11 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} return nil, ErrNotReady } - if time.Since(v.lastFetch) > time.Hour { - if err := v.fetchPublicKeys(); err != nil { + v.keysMu.RLock() + lastFetch := v.lastFetch + v.keysMu.RUnlock() + if time.Since(lastFetch) > time.Hour { + if err := v.refreshKeys(); err != nil { v.logger.Error("failed to refresh public keys", "error", err) } } @@ -226,8 +265,9 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} return publicKey, nil } - // Key not cached — Keycloak may have rotated keys. Try a one-shot refresh. - if refreshErr := v.fetchPublicKeys(); refreshErr != nil { + // Key not cached — Keycloak may have rotated keys. Try a rate-limited, + // de-duplicated refresh (see unknownKIDCooldown for why this is guarded). + if refreshErr := v.refreshForUnknownKID(); refreshErr != nil { return nil, fmt.Errorf("unknown key ID %s and key refresh failed: %w", kid, refreshErr) } v.keysMu.RLock() @@ -245,17 +285,58 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} return nil, fmt.Errorf("invalid token") } - // Expiry is already enforced by ParseWithClaims (with clockLeeway); only the - // issuer needs a manual check. `aud`/`azp` are intentionally not checked, to - // match the reference webapi validation. + // Expiry is already enforced by ParseWithClaims (with clockLeeway); the issuer + // and (optionally) the authorized party are checked manually here. expectedIssuer := fmt.Sprintf("%s/realms/%s", v.issuerURL, v.realm) if iss, _ := claims["iss"].(string); iss != expectedIssuer { return nil, fmt.Errorf("invalid issuer: expected %s, got %s", expectedIssuer, iss) } + // The `nebari` realm is shared across all Nebari apps, so `iss` alone accepts + // a token minted for any client in the realm. When expectedClientID is set, + // pin `azp` to reject tokens obtained by other clients. Public PKCE clients + // are not placed in `aud` by Keycloak, so `azp` — not `aud` — is the claim + // that identifies the client that obtained the token. + if v.expectedClientID != "" { + if azp, _ := claims["azp"].(string); azp != v.expectedClientID { + return nil, fmt.Errorf("invalid azp: expected %s, got %s", v.expectedClientID, azp) + } + } + return claims, nil } +// refreshKeys performs a JWKS refresh, collapsing concurrent callers into a +// single in-flight outbound request via singleflight so a burst of requests +// arriving at the same time produces exactly one call to Keycloak. +func (v *JWTValidator) refreshKeys() error { + _, err, _ := v.refreshGroup.Do("certs", func() (interface{}, error) { + return nil, v.fetchPublicKeys() + }) + return err +} + +// refreshForUnknownKID refreshes the JWKS in response to a token carrying an +// unrecognized `kid`, rate-limited to at most one attempt per unknownKIDCooldown. +// This runs before signature verification on attacker-controllable input, so the +// cooldown (plus singleflight in refreshKeys) is what prevents forged tokens with +// random `kid`s from amplifying request volume into a fetch storm against +// Keycloak. Returns errKIDRefreshCooldown when suppressed. +func (v *JWTValidator) refreshForUnknownKID() error { + now := time.Now().UnixNano() + last := v.lastKIDRefresh.Load() + if last != 0 && now-last < int64(unknownKIDCooldown) { + return errKIDRefreshCooldown + } + // CAS ensures only one goroutine per cooldown window proceeds to the fetch; + // losers are treated as suppressed (singleflight would dedup them anyway, but + // this also bounds the rate when fetches fail and return quickly). + if !v.lastKIDRefresh.CompareAndSwap(last, now) { + return errKIDRefreshCooldown + } + return v.refreshKeys() +} + func (v *JWTValidator) fetchPublicKeys() error { certsURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", v.keycloakURL, v.realm) diff --git a/key-manager/internal/api/jwt_validator_test.go b/key-manager/internal/api/jwt_validator_test.go new file mode 100644 index 0000000..cbc6d4b --- /dev/null +++ b/key-manager/internal/api/jwt_validator_test.go @@ -0,0 +1,180 @@ +package api + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// writeJWKS encodes pub as a single-key JWKS under kid, matching the shape the +// validator's fetchPublicKeys expects from Keycloak's /certs endpoint. +func writeJWKS(t *testing.T, w http.ResponseWriter, kid string, pub *rsa.PublicKey) { + t.Helper() + set := jwks{Keys: []jwk{{ + Kty: "RSA", + Kid: kid, + Use: "sig", + N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}} + if err := json.NewEncoder(w).Encode(set); err != nil { + t.Fatalf("encoding jwks: %v", err) + } +} + +// signTokenWithKID signs claims with an arbitrary `kid` header so tests can +// forge tokens whose key ID is not in the validator's cache. +func signTokenWithKID(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { + t.Helper() + if _, ok := claims["iss"]; !ok { + claims["iss"] = testIssuer + "/realms/" + testRealm + } + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(5 * time.Minute).Unix() + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = kid + s, err := tok.SignedString(priv) + if err != nil { + t.Fatalf("signing token: %v", err) + } + return s +} + +// TestUnknownKIDRefreshIsRateLimited guards against DoS amplification: a caller +// sending tokens with unrecognized `kid`s (verifiable before signature check, +// since the gateway forwards Authorization as-is under enforceAtGateway:false) +// must not turn each request into an outbound JWKS fetch. singleflight collapses +// a concurrent burst into one fetch, and unknownKIDCooldown suppresses the rest. +func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { + priv := genKey(t) + + var fetchCount atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetchCount.Add(1) + writeJWKS(t, w, testKID, &priv.PublicKey) + })) + defer srv.Close() + + v := &JWTValidator{ + logger: slog.Default(), + keycloakURL: srv.URL, + issuerURL: testIssuer, + realm: testRealm, + publicKeys: map[string]*rsa.PublicKey{testKID: &priv.PublicKey}, + lastFetch: time.Now(), + } + v.ready.Store(true) + + // The server never returns "unknown-kid", so every attempt is a genuine cache + // miss that would fetch if unguarded. + forged := signTokenWithKID(t, priv, "unknown-kid", jwt.MapClaims{"preferred_username": "mallory"}) + + const burst = 50 + var wg sync.WaitGroup + for range burst { + wg.Go(func() { + // All of these must fail (kid never resolves); we only care about fetches. + _, _ = v.ValidateToken(forged) + }) + } + wg.Wait() + + if got := fetchCount.Load(); got != 1 { + t.Fatalf("expected exactly 1 JWKS fetch for a burst of %d unknown-kid tokens, got %d", burst, got) + } + + // A further attempt inside the cooldown window must not fetch again. + _, _ = v.ValidateToken(forged) + if got := fetchCount.Load(); got != 1 { + t.Fatalf("expected cooldown to suppress refresh, got %d fetches", got) + } + + // Once the cooldown has elapsed (simulated by clearing the timestamp), a new + // unknown-kid attempt is allowed to refresh again. + v.lastKIDRefresh.Store(0) + _, _ = v.ValidateToken(forged) + if got := fetchCount.Load(); got != 2 { + t.Fatalf("expected a refresh after cooldown elapsed, got %d fetches", got) + } +} + +// TestAzpValidation covers the opt-in `azp` pin: when an expected client ID is +// set, only tokens whose `azp` matches are accepted; when unset, `azp` is ignored. +func TestAzpValidation(t *testing.T) { + priv := genKey(t) + + tests := []struct { + name string + expectedClientID string + azp interface{} // nil = omit the claim + wantErr bool + }{ + {"no pin accepts matching azp", "", "key-manager-spa", false}, + {"no pin accepts foreign azp", "", "some-other-client", false}, + {"no pin accepts missing azp", "", nil, false}, + {"pin accepts matching azp", "key-manager-spa", "key-manager-spa", false}, + {"pin rejects foreign azp", "key-manager-spa", "some-other-client", true}, + {"pin rejects missing azp", "key-manager-spa", nil, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := newTestValidator(t, &priv.PublicKey) + v.SetExpectedClientID(tc.expectedClientID) + + claims := jwt.MapClaims{"preferred_username": "alice"} + if tc.azp != nil { + claims["azp"] = tc.azp + } + _, err := v.ValidateToken(signToken(t, priv, claims)) + + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// TestKnownKIDNeverRefetches confirms the happy path is fetch-free: a token +// whose kid is already cached validates without any outbound call. +func TestKnownKIDNeverRefetches(t *testing.T) { + priv := genKey(t) + + var fetchCount atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetchCount.Add(1) + writeJWKS(t, w, testKID, &priv.PublicKey) + })) + defer srv.Close() + + v := &JWTValidator{ + logger: slog.Default(), + keycloakURL: srv.URL, + issuerURL: testIssuer, + realm: testRealm, + publicKeys: map[string]*rsa.PublicKey{testKID: &priv.PublicKey}, + lastFetch: time.Now(), + } + v.ready.Store(true) + + if _, err := v.ValidateToken(signToken(t, priv, jwt.MapClaims{"preferred_username": "alice"})); err != nil { + t.Fatalf("validating a token with a cached kid: %v", err) + } + if got := fetchCount.Load(); got != 0 { + t.Fatalf("expected no JWKS fetch for a cached kid, got %d", got) + } +} From be84cf9e111afff1b5cd02f811878f18166cb5dc Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Thu, 9 Jul 2026 15:07:50 -0400 Subject: [PATCH 13/15] Address round-2 PR review: rotation 401 blocker + cleanups Blocker: the unknown-kid cooldown gate 401'd legitimate tokens during a real Keycloak key rotation. The SPA fires /api/me, /api/models and /api/keys concurrently on page load, so on the first load after a rotation one request won the CAS and fetched while the others got errKIDRefreshCooldown and were rejected. Move the cooldown check inside the singleflight callback so concurrent callers block on the one in-flight fetch and share its result, and re-check the cache in keyFunc regardless of the refresh outcome. The DoS bound (one outbound fetch per 30s window) is preserved. Add TestRotatedKIDResolvesForConcurrentBurst. Take the steady-state JWKS refresh off the request path: a synchronous hourly refresh stalled every concurrent request for up to the fetch timeout when Keycloak was unreachable as the cache went stale. Move it to a background ticker (refreshLoop) and drop the now-dead lastFetch. Other review items: - Cap the JWKS response body at 1 MiB (io.LimitReader). - Call validator.Stop() on shutdown. - Add security headers (X-Frame-Options, CSP frame-ancestors, X-Content-Type-Options) and a no-store /config.json block to both nginx configs; the UI exposes one-click revoke. - Template the NebariApp groups mapper claim.name from auth.oidc.groupsClaim so it can't drift from what the key-manager reads. - Comment provider: keycloak pointing at #61. - Note pinClientAudience is only meaningful with frontend.enabled=true. - Fix the stale ui-development.md shipping instructions. - Delete the stale frontend-rewrite-plan.md. - Drop an em dash in test-auth.sh. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/frontend-configmap.yaml | 17 ++ .../templates/key-manager-nebariapp.yaml | 9 +- charts/nebari-llm-serving/values.yaml | 5 + dev/test-auth.sh | 2 +- docs/src/content/docs/ui-development.md | 16 +- frontend-rewrite-plan.md | 234 ------------------ frontend/nginx.default.conf | 17 ++ key-manager/cmd/main.go | 7 +- key-manager/internal/api/jwt_validator.go | 132 +++++++--- .../internal/api/jwt_validator_test.go | 54 +++- key-manager/internal/api/middleware_test.go | 1 - 11 files changed, 204 insertions(+), 290 deletions(-) delete mode 100644 frontend-rewrite-plan.md diff --git a/charts/nebari-llm-serving/templates/frontend-configmap.yaml b/charts/nebari-llm-serving/templates/frontend-configmap.yaml index bb00c5d..91f3be7 100644 --- a/charts/nebari-llm-serving/templates/frontend-configmap.yaml +++ b/charts/nebari-llm-serving/templates/frontend-configmap.yaml @@ -62,6 +62,15 @@ data: root /usr/share/nginx/html; index index.html; + # Security headers. add_header does NOT inherit into a location that + # sets its own add_header, so these cover the SPA document (location + # /); the config.json block below repeats the ones that matter for it. + # The framing headers matter because the UI exposes one-click "revoke + # key" — deny embedding to block clickjacking. + add_header X-Frame-Options "DENY" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header X-Content-Type-Options "nosniff" always; + gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript @@ -91,6 +100,14 @@ data: add_header Content-Type text/plain; } + # Runtime config: never cache, so Keycloak settings changes take + # effect on the next page load instead of being pinned by a stale copy. + location = /config.json { + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri =404; + } + # SPA fallback. location / { try_files $uri $uri/ /index.html; diff --git a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml index 7a06f65..103a7d2 100644 --- a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml @@ -33,18 +33,23 @@ spec: # provisions the public SPA client but emits no gateway SecurityPolicy. auth: enabled: true + # Hardcoded to keycloak: the key-manager only validates Keycloak-minted + # tokens today. generic-oidc support is tracked in nebari-dev/#61. provider: keycloak enforceAtGateway: false scopes: {{- toYaml .Values.keyManager.nebariApp.auth.scopes | nindent 6 }} # The groups mapper ensures access tokens carry group claims so the - # key-manager can filter models by group membership. + # key-manager can filter models by group membership. claim.name is derived + # from the same value the key-manager reads (auth.oidc.groupsClaim, wired to + # LLM_OIDC_GROUPS_CLAIM) so overriding the claim name can't drift the mapper + # and the reader out of sync. keycloakConfig: protocolMappers: - name: group-membership protocolMapper: oidc-group-membership-mapper config: - claim.name: groups + claim.name: {{ .Values.auth.oidc.groupsClaim | quote }} full.path: "false" id.token.claim: "true" access.token.claim: "true" diff --git a/charts/nebari-llm-serving/values.yaml b/charts/nebari-llm-serving/values.yaml index 84ccfb5..10d3a9d 100644 --- a/charts/nebari-llm-serving/values.yaml +++ b/charts/nebari-llm-serving/values.yaml @@ -100,6 +100,11 @@ keyManager: # realm (issuer-only validation). The client ID is derived from # frontend.keycloak.clientId (or the operator convention), so it always # matches the token the SPA actually presents. + # + # Only meaningful with frontend.enabled=true: the pinned client is the SPA + # client the NebariApp provisions, so enabling this on an API-only install + # (frontend.enabled=false) pins an `azp` that no token in the realm carries + # and every request 401s. pinClientAudience: false # nebariApp - the NebariApp CR that exposes the key-manager UI via the # nebari-operator (HTTPRoute, TLS cert, Keycloak client, landing-page tile). diff --git a/dev/test-auth.sh b/dev/test-auth.sh index 9506c73..78543f0 100755 --- a/dev/test-auth.sh +++ b/dev/test-auth.sh @@ -112,7 +112,7 @@ check() { # name expected actual if [[ "$2" == "$3" ]]; then echo " PASS: $1 (HTTP $3)" else - echo " FAIL: $1 — expected $2, got $3" + echo " FAIL: $1 - expected $2, got $3" FAILED=1 fi } diff --git a/docs/src/content/docs/ui-development.md b/docs/src/content/docs/ui-development.md index 80e24c9..bb2078c 100644 --- a/docs/src/content/docs/ui-development.md +++ b/docs/src/content/docs/ui-development.md @@ -92,14 +92,14 @@ npm run check # biome lint + format The UI ships as its own image (`ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend`, nginx serving the built bundle) - it is no longer embedded in the Go key-manager binary. Committing your -edits to `frontend/` is all that is needed for them to ship in the next image -build. To see your changes in the actual in-cluster pod (rather than the dev -server), rebuild and reload: - -```bash -make build-images && make load-images -kubectl -n llm-operator-system rollout restart deployment/llm-frontend -``` +edits to `frontend/` is all that is needed for them to ship: CI builds and +publishes the frontend image on merge, and a chart upgrade rolls it out. + +During local development you iterate against the Vite dev server (`make ui` in +`dev/`), which hot-reloads on save and proxies `/api` to the port-forwarded +key-manager - there is no in-cluster frontend pod in the dev stack to rebuild or +restart. (`make build-images` builds only the operator, key-manager, and +mock-vllm images.) ## API reference (what the UI calls) diff --git a/frontend-rewrite-plan.md b/frontend-rewrite-plan.md deleted file mode 100644 index f8bf737..0000000 --- a/frontend-rewrite-plan.md +++ /dev/null @@ -1,234 +0,0 @@ -# Frontend Rewrite Plan — React + TypeScript (issue #92) - -Rebuild the LLM Serving Pack UI from vanilla HTML/CSS/JS into a React + TypeScript -app (Vite + shadcn/ui + Tailwind v4), served as its own image, at feature parity -with the current API Key Manager. - -Tracking issue: **#92 — Rebuild the LLM Serving Pack UI with React + TypeScript** - ---- - -## Locked decisions - -- **Design system:** copy the `@theme` tokens + `ThemeProvider`/`useThemePreference` - dark-mode pattern from **nebari-landing**. No external design-system package. -- **Delivery:** serve the frontend **separately** as its own nginx image — not - embedded in the Go `key-manager` binary. -- **Location:** the app lives at the repo root in **`frontend/`** (its own component, - own Dockerfile, own CI job), consistent with `operator/`, `key-manager/`, - `model-downloader/`. -- **Stack:** React 19 + TS + Vite 7, Tailwind v4 (`@tailwindcss/vite`), - shadcn/ui + radix-ui, lucide-react, TanStack Query v5, Jotai v2, Biome 2, - Vitest 4 + Testing Library. npm, dev port 5173. -- **Auth: Model B — SPA-managed Keycloak** (matches nebari-landing). The SPA - owns login via `keycloak-js` (`onLoad: "login-required"` + PKCE) and attaches - `Authorization: Bearer ` on every `/api` call (refresh on 401, retry - once, else redirect to login). Keycloak `{ url, realm, clientId }` is loaded - at runtime from **`/config.json`** (Helm-rendered, mounted into nginx — no - rebuild to change). **Correction to the original plan:** auth is **not** - enforced at the gateway. The `NebariApp` uses `enforceAtGateway: false` + - `spaClient.enabled: true` (the operator provisions a public PKCE client), so - there is no Envoy JWT `SecurityPolicy` and no oauth2-proxy sidecar. Instead the - **key-manager validates the bearer in-process** against the Keycloak realm's - JWKS (RSA signature + `exp` with 30s leeway + exact `iss` match; audience not - checked), reading identity/groups from the claims — a real backend change, - mirroring nebari-landing. It is configured by `LLM_KEYCLOAK_URL`, - `LLM_KEYCLOAK_REALM`, and `LLM_KEYCLOAK_ISSUER_URL`. - -## Reference template — nebari-landing (`~/repos/nebari-landing`) - -Same architecture (Go backend + separate `frontend/` Vite app). Copy-ready patterns: - -| Concern | File in nebari-landing | -|---|---| -| Build → nginx image, SPA `try_files` fallback | `frontend/Dockerfile` | -| `@` alias + `/api` dev proxy to `localhost:8080` | `frontend/vite.config.ts` | -| Semantic tokens, light/dark, `@custom-variant dark` | `frontend/src/app/index.css` | -| System-default theme (localStorage + `matchMedia`) | `frontend/src/hooks/useThemePreference.ts`, `ThemeContext.tsx` | - ---- - -## Current state (what we're replacing) - -The UI is a single-page **API Key Manager** embedded in the Go binary -(`//go:embed static/*` → `http.FileServer`): - -- `key-manager/internal/ui/static/{index.html, app.js, style.css, *.svg}` (~950 LOC) -- `key-manager/internal/ui/embed.go` -- static serving wired in `key-manager/cmd/main.go:110-114` - -**Backend API (unchanged by this work):** - -- `GET /api/me`, `GET /api/models`, `GET /api/keys`, `POST /api/keys`, - `DELETE /api/keys/{namespace}/{model}/{clientID}`, `/logout` -- OIDC-cookie auth middleware applied to `/api/*` only. - -**Functional surface to preserve (one page):** - -- Topbar: Nebari logo + account dropdown (name/email, Sign out → `/logout`) -- Page header + global error banner -- "My API Keys" card: Create button + table (Name/Description, Client ID, Model, - Created, kebab → Revoke); loading / empty / error states -- Dialogs: Create Key (model select + description + validation), Key Created - (client ID, one-time key, copy, download `.txt`, warning), Revoke confirmation - ---- - -## Auth + deployment shape (Model B) - -Auth is **SPA-managed Keycloak with bearer tokens** (see Locked decisions). This -relaxes the same-origin cookie constraint — the token rides in the -`Authorization` header, not a host-scoped cookie — but we still serve the SPA and -API under one host for simplicity and so the JWT `SecurityPolicy` covers both. - -**Deployment approach:** the **nginx frontend container is the service the -NebariApp targets**; nginx serves the SPA + `/config.json` and reverse-proxies -`/api/*` to the key-manager ClusterIP (`:8080`). The Go key-manager drops static -serving and becomes API-only. **As built (corrected):** the gateway does **not** -enforce auth (`enforceAtGateway: false`, public `spaClient`) — there is no Envoy -JWT `SecurityPolicy`. The **key-manager validates the bearer itself** against -Keycloak's JWKS, so it now verifies the signature (previously it only parsed -claims). The key-manager Service stays internal-only and is never exposed -through the gateway. - -Login/logout are driven by `keycloak-js` in the SPA (redirect to Keycloak), so -the old `/logout` proxy route is replaced by `keycloak.logout()`. - ---- - -## Phases - -### Phase 1 — Scaffold `frontend/` ✅ -- [x] Vite + React 19 + TS project at repo-root `frontend/` -- [x] Tailwind v4 via `@tailwindcss/vite`; shadcn (`components.json`) + radix-ui -- [x] Deps: lucide-react, TanStack Query v5, Jotai v2, Biome 2, Vitest 4 + Testing Library -- [x] `package.json` scripts: `dev`, `build` (`tsc -b && vite build`), `preview`, - `check` (biome), `test` / `test:run` (vitest) -- [x] `vite.config.ts` with `@` alias + `/api` (and `/logout`) dev proxy → `http://localhost:8080` -- [x] Quality gate green: `npm run build`, `npm run test:run`, `npm run check` all pass - -### Phase 2 — Theming ✅ -- [x] Copy nebari-landing tokens into `src/index.css` (light/dark, `@theme inline`, `@custom-variant dark`) -- [x] Copy `useThemePreference.ts` + `useLocalStorageState.ts`; add `ThemeProvider` (`src/providers/ThemeProvider/`); system default -- [x] `ThemeProvider` wired into `main.tsx` -- [x] `components.json` baseColor/aliases aligned (style `radix-vega`, css `src/index.css`) -- [x] Test setup mocks (`localStorage`, `matchMedia`) so theme hooks test cleanly; gate green - - Note: `npm test` runs vitest once (`vitest --run`); no separate `test:run`. - -### Phase 3 — Data + state layer ✅ -- [x] `lib/api.ts` fetch wrapper (`api.get/post/delete`, `ApiError` w/ status, JSON, 204 → null) -- [x] `lib/types.ts` — API shapes; `RawModelInfo` (PascalCase, no Go json tags) normalized to `Model` -- [x] `lib/queryClient.ts` — configured `QueryClient` (retry off, 30s staleTime) -- [x] TanStack Query hooks: `useCurrentUser`, `useModels`, `useApiKeys` + `useCreateKey` + `useRevokeKey` - (mutations invalidate `["keys"]`) -- [x] `store/dialogAtoms.ts` — Jotai discriminated-union dialog atom (none/create/created/revoke) -- [x] `QueryClientProvider` + `ThemeProvider` wired in `main.tsx` -- [x] Tests: `api.test.ts`, `useApiKeys.test.tsx`; gate green (11 tests) - -### Phase 4 — Components (feature parity) ✅ -Each component gets its own PascalCase dir + `index.ts` barrel. -- [x] `Topbar` (logo, account dropdown: name/email, theme radio light/dark/system, Sign out → `/logout`) -- [x] `KeysCard` (table + loading/empty/error states + Create button) -- [x] `KeyRowActions` (kebab → Revoke, destructive) -- [x] `CreateKeyDialog` (model select + description + validation) -- [x] `KeyCreatedDialog` (client ID, one-time key, copy w/ feedback, download `.txt`, warning) -- [x] `RevokeKeyDialog` (destructive confirm) -- [x] `ErrorBanner` (dismissible, driven by `errorAtom`) -- [x] shadcn primitives in `components/ui/`: button, card, table, dropdown-menu, avatar, - input, badge (copied from nebari-landing) + dialog, select, label, alert (shadcn CLI) -- [x] Dialogs driven by `dialogAtom`; `lib/format.ts` (date + initials); `App.tsx` composes the page -- [x] Tests: `App.test.tsx`, `KeysCard.test.tsx`, `src/test/render.tsx` provider helper; gate green (14 tests) - -> Not yet done: visual verification against a **live backend** + Figma comparison. Deferred to -> Phase 6 (local dev brings up the API) / Phase 7 (polish). The build, type-check, and tests pass. - -### Phase 4b — Auth (Model B: SPA-managed Keycloak) ✅ -Mirrors nebari-landing's `src/auth/*`, `src/api/client.ts`, `src/app/config.ts`. -- [x] Add `keycloak-js` dependency -- [x] `src/app/config.ts` — load + cache `/config.json` (`keycloak: { url, realm, clientId }`, optional title) -- [x] `src/auth/keycloak.ts` — `initKeycloak()` (`login-required` + PKCE), `getToken()` w/ refresh, - `SessionExpiredError`, `signOut()` → `keycloak.logout()`, `__PW_E2E_AUTH__` shim -- [x] `src/auth/user.ts` — `useUser()` reads identity from the ID token; `Topbar` now uses it -- [x] `api.ts` — attach `Authorization: Bearer `; on 401 refresh + retry once -- [x] `main.tsx` — top-level `await loadAppConfig()` + `await initKeycloak()` before render -- [x] `Topbar` Sign out → `signOut()` (dropped the `/logout` anchor) -- [x] Test setup injects the `__PW_E2E_AUTH__` shim so api calls get a token; `public/config.json` gitignored -- [x] Tests updated/added for the bearer + 401-retry path; gate green (15 tests) - -> Note: `src/hooks/useCurrentUser.ts` (GET /api/me) is no longer used by the UI (identity now -> comes from the ID token) but kept as a valid endpoint wrapper. Remove in cleanup if still unused. - -### Phase 5 — Serve separately (Docker + Helm + CI) ✅ -- [x] `frontend/Dockerfile` (node build → nginx; SPA `try_files`; `location /api` - `proxy_pass` to key-manager service) -- [x] `frontend/nginx.conf` — also serves `/config.json` (Helm-rendered, mounted) -- [x] Gut `key-manager/internal/ui/` (remove `embed.go` + `static/`); drop file - server / SPA route from `cmd/main.go` → key-manager is API-only -- [x] Helm: new `frontend` Deployment + Service + config ConfigMap -- [x] Helm: render `/config.json` (ConfigMap from `frontend.keycloak.*` values) into nginx -- [x] Repoint `key-manager-nebariapp.yaml` `service:` at the frontend service -- [x] Gateway auth is **Model B without a gateway `SecurityPolicy`**: NebariApp - `enforceAtGateway: false` + `spaClient.enabled: true`; the **key-manager** - validates the bearer against Keycloak JWKS (`LLM_KEYCLOAK_*` env). (Not the - original Envoy JWT `SecurityPolicy` plan.) -- [x] Add `frontend.image.*` + `frontend.keycloak.*` (and `keyManager.keycloak.*`) to `values.yaml` -- [x] CI: `build-frontend` job in `build-images.yaml` -- [x] CI: `lint-frontend` (biome) + `test-frontend` (vitest) jobs in `lint.yaml` / `test.yaml` - -### Phase 6 — Local dev -- [x] Dev Keycloak in the kind cluster: `dev/manifests/keycloak.yaml` (`start-dev - --import-realm`) + `dev/keycloak/realm-nebari.json` (realm `nebari`, public - PKCE client `nebari-frontend-spa`, groups mapper, `testuser`/`testuser`); - `make deploy-keycloak` renders the realm ConfigMap + deploys; `make - pf-keycloak` (8180) / `pf-key-manager` (8080) port-forward helpers -- [x] Wire `frontend/` into `dev/`: `cd dev && ./run-dev.sh` (also `make run-dev` - / `make ui`) brings up the kind cluster + dev-mode key-manager - (`LLM_DEV_MODE=true`) + Vite dev server at :5173 with `VITE_DEV_NO_AUTH=true`, - proxying `/api/*` to the port-forwarded key-manager on :8080. The old - `go:embed` static UI and the `dev/uidev` Go live-reload server are removed. -- [x] Document Vite `/api` proxy + local `config.json` + how Keycloak/bearer auth is - stubbed for standalone runs (E2E auth shim or a real Keycloak) - -> Inner loop: `cd dev && make setup build-images load-images deploy deploy-keycloak`, -> then `make pf-keycloak` + `make pf-key-manager` (separate terminals), then -> `cd frontend && npm run dev`. `frontend/public/config.json` already points at -> `http://localhost:8180` / realm `nebari` / client `nebari-frontend-spa`. Mint a -> token without the browser via the client's direct-access grant: -> `curl -d client_id=nebari-frontend-spa -d username=testuser -d password=testuser -> -d grant_type=password http://localhost:8180/realms/nebari/protocol/openid-connect/token` - -### Phase 7 — Quality gate, cleanup, docs -- [x] `npm run build && npm run test:run && npm run check` all pass -- [x] Remove old vanilla files (`key-manager/internal/ui/`, `dev/uidev`) after parity confirmed -- [x] Refresh README / getting-started UI references (screenshots unchanged) - ---- - -## Open items — resolved during build - -- [x] `NebariApp` routes a **single** service under the host (the frontend nginx), - which reverse-proxies `/api/*` to the key-manager — so the nginx `/api` proxy - stays. (No two-services-per-host routing needed.) -- [x] The nginx proxy targets the internal key-manager ClusterIP Service on `:8080` - over in-cluster DNS; that Service is never exposed through the gateway. -- [x] Gateway does **not** use a JWT `SecurityPolicy` for the key-manager host. - Auth is `enforceAtGateway: false` + a public `spaClient`; the SPA reads - `keycloak.{url,realm,clientId}` from `/config.json` (from `frontend.keycloak.*`). -- [x] **Decided: the key-manager validates the bearer signature itself** against - Keycloak JWKS (`LLM_KEYCLOAK_*`), rather than trusting the gateway. This was a - real backend change (the middleware now verifies signatures). - ---- - -## Acceptance criteria (from #92) - -- [ ] Vite + React + TS scaffold per `frontend-dev` conventions -- [ ] Tailwind v4 wired to Nebari design-system tokens (semantic, not hardcoded) -- [ ] shadcn/ui via `components.json`; UI composed on design-system components -- [ ] All existing screens reimplemented at feature parity -- [ ] State/data fetching per conventions (TanStack Query + Jotai where applicable) -- [ ] UI matches Figma + uses shared design-system components -- [ ] Dark-mode via profile menu, defaulting to System -- [ ] Biome + Vitest gates pass (`biome check`, `vitest run`) -- [ ] Build/dev/lint/test scripts in `package.json` -- [ ] Old vanilla frontend removed once parity confirmed diff --git a/frontend/nginx.default.conf b/frontend/nginx.default.conf index 8315309..498406c 100644 --- a/frontend/nginx.default.conf +++ b/frontend/nginx.default.conf @@ -36,6 +36,15 @@ http { root /usr/share/nginx/html; index index.html; + # Security headers. add_header does NOT inherit into a location that sets + # its own add_header, so these cover the SPA document (location /); the + # config.json block below repeats the ones that matter for it. The framing + # headers matter because the UI exposes one-click "revoke key" — deny + # embedding to block clickjacking. + add_header X-Frame-Options "DENY" always; + add_header Content-Security-Policy "frame-ancestors 'none'" always; + add_header X-Content-Type-Options "nosniff" always; + gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript @@ -55,6 +64,14 @@ http { add_header Content-Type text/plain; } + # Runtime config: never cache, so Keycloak settings changes take effect on + # the next page load instead of being pinned by a stale cached copy. + location = /config.json { + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri =404; + } + # SPA fallback. location / { try_files $uri $uri/ /index.html; diff --git a/key-manager/cmd/main.go b/key-manager/cmd/main.go index 020c094..8a997b8 100644 --- a/key-manager/cmd/main.go +++ b/key-manager/cmd/main.go @@ -123,6 +123,7 @@ func main() { GroupsClaim: groupsClaim, DevMode: devMode, } + var validator *api.JWTValidator if devMode { authConfig.DevIdentity = api.UserInfo{ Username: devUser, @@ -136,7 +137,7 @@ func main() { if keycloakURL == "" { log.Fatal("LLM_KEYCLOAK_URL is required unless LLM_DEV_MODE=true") } - validator := api.NewJWTValidator(keycloakURL, keycloakRealm, logger) + validator = api.NewJWTValidator(keycloakURL, keycloakRealm, logger) validator.SetIssuerURL(keycloakIssuerURL) validator.SetExpectedClientID(keycloakSPAClientID) authConfig.Validator = validator @@ -171,6 +172,10 @@ func main() { if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("http server shutdown error", "error", err) } + // Stop the validator's background JWKS refresh goroutine. + if validator != nil { + validator.Stop() + } }() log.Printf("key-manager listening on %s", listenAddr) diff --git a/key-manager/internal/api/jwt_validator.go b/key-manager/internal/api/jwt_validator.go index 5149147..7d0e5a7 100644 --- a/key-manager/internal/api/jwt_validator.go +++ b/key-manager/internal/api/jwt_validator.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "math/big" "net/http" @@ -31,6 +32,13 @@ var ( retryDelay = time.Sleep // slowPollInterval is the cadence for the post-retry "keep trying" loop. slowPollInterval = 30 * time.Second + // keyRefreshInterval is the steady-state cadence at which the background + // goroutine proactively re-fetches the JWKS once the validator is ready, so a + // key rotation is picked up without waiting for a request to miss the cache. + // Kept deliberately off the request path (see refreshLoop): a synchronous + // hourly refresh would stall every concurrent request for up to the fetch + // timeout whenever Keycloak is unreachable at the moment the cache goes stale. + keyRefreshInterval = time.Hour ) // ErrNotReady is returned by ValidateToken when the validator's initial JWKS @@ -61,6 +69,10 @@ const unknownKIDCooldown = 30 * time.Second // because another refresh happened within unknownKIDCooldown. var errKIDRefreshCooldown = errors.New("unknown-kid JWKS refresh suppressed by cooldown") +// maxJWKSBytes caps the JWKS response body read from Keycloak. A realm key set is +// a few KB; 1 MiB bounds memory against an oversized or unbounded response. +const maxJWKSBytes = 1 << 20 + // jwk is a single JSON Web Key from Keycloak's JWKS endpoint. type jwk struct { Kty string `json:"kty"` @@ -102,8 +114,7 @@ type JWTValidator struct { expectedClientID string publicKeys map[string]*rsa.PublicKey keysMu sync.RWMutex - lastFetch time.Time - // refreshGroup collapses concurrent JWKS refreshes (hourly re-fetch and + // refreshGroup collapses concurrent JWKS refreshes (periodic re-fetch and // unknown-kid re-fetch) into a single in-flight outbound request. refreshGroup singleflight.Group // lastKIDRefresh is the unix-nano timestamp of the most recent unknown-kid @@ -163,21 +174,30 @@ func (v *JWTValidator) SetExpectedClientID(clientID string) { v.expectedClientID = clientID } -// initLoop runs the initial JWKS fetch with exponential backoff, then falls -// back to a slow poll if all active attempts fail. It exits as soon as any -// fetch succeeds or when Stop() is called. +// initLoop drives the validator's background goroutine: it first brings the +// validator online (initialFetch), then keeps the JWKS fresh on a steady cadence +// (refreshLoop). It exits, closing doneCh, only when Stop() is called. func (v *JWTValidator) initLoop() { defer close(v.doneCh) + if v.initialFetch() { + v.refreshLoop() + } +} +// initialFetch runs the initial JWKS fetch with exponential backoff, then falls +// back to a slow poll if all active attempts fail. It returns true as soon as a +// fetch succeeds (marking the validator ready), or false if Stop() is called +// before that happens. +func (v *JWTValidator) initialFetch() bool { backoff := retryInitialBackoff for attempt := 1; attempt <= retryMaxAttempts; attempt++ { if v.stopped() { - return + return false } if err := v.fetchPublicKeys(); err == nil { v.ready.Store(true) v.logger.Info("JWT validator ready", "attempt", attempt) - return + return true } else { v.logger.Warn("failed to fetch Keycloak public keys, retrying", "attempt", attempt, "maxRetries", retryMaxAttempts, "backoff", backoff, "error", err, @@ -196,19 +216,40 @@ func (v *JWTValidator) initLoop() { for { select { case <-v.stopCh: - return + return false case <-time.After(slowPollInterval): } if err := v.fetchPublicKeys(); err == nil { v.ready.Store(true) v.logger.Info("JWT validator ready (slow poll)") - return + return true } else { v.logger.Warn("slow poll JWKS fetch failed", "error", err) } } } +// refreshLoop proactively re-fetches the JWKS every keyRefreshInterval until +// Stop() is called, keeping key rotation off the request path. A failed refresh +// is logged and retried on the next tick; the previously-cached keys remain in +// use in the meantime, so a transient Keycloak outage never fails validation +// (fail-open on stale keys). The unknown-kid path in ValidateToken remains the +// fast trigger for a rotation that lands between ticks. +func (v *JWTValidator) refreshLoop() { + ticker := time.NewTicker(keyRefreshInterval) + defer ticker.Stop() + for { + select { + case <-v.stopCh: + return + case <-ticker.C: + if err := v.refreshKeys(); err != nil { + v.logger.Error("background JWKS refresh failed; keeping cached keys", "error", err) + } + } + } +} + func (v *JWTValidator) stopped() bool { select { case <-v.stopCh: @@ -239,14 +280,8 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} return nil, ErrNotReady } - v.keysMu.RLock() - lastFetch := v.lastFetch - v.keysMu.RUnlock() - if time.Since(lastFetch) > time.Hour { - if err := v.refreshKeys(); err != nil { - v.logger.Error("failed to refresh public keys", "error", err) - } - } + // The steady-state JWKS refresh runs on the background goroutine (refreshLoop), + // not here, so a slow or unreachable Keycloak never stalls request handling. claims := jwt.MapClaims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { @@ -265,11 +300,15 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} return publicKey, nil } - // Key not cached — Keycloak may have rotated keys. Try a rate-limited, - // de-duplicated refresh (see unknownKIDCooldown for why this is guarded). - if refreshErr := v.refreshForUnknownKID(); refreshErr != nil { - return nil, fmt.Errorf("unknown key ID %s and key refresh failed: %w", kid, refreshErr) - } + // Key not cached — Keycloak may have rotated keys. Trigger a rate-limited, + // de-duplicated refresh, then re-check the cache regardless of the refresh + // outcome. A concurrent burst with the same freshly-rotated kid all funnels + // through the one in-flight fetch (singleflight) and finds the key here; a + // caller whose refresh was suppressed by the cooldown may still find the key + // populated by a fetch that just completed. Only a genuinely absent kid + // (forged, or a bad token) falls through to the error below. See + // unknownKIDCooldown / refreshForUnknownKID for why the refresh is guarded. + _ = v.refreshForUnknownKID() v.keysMu.RLock() publicKey, exists = v.publicKeys[kid] v.keysMu.RUnlock() @@ -317,24 +356,33 @@ func (v *JWTValidator) refreshKeys() error { } // refreshForUnknownKID refreshes the JWKS in response to a token carrying an -// unrecognized `kid`, rate-limited to at most one attempt per unknownKIDCooldown. -// This runs before signature verification on attacker-controllable input, so the -// cooldown (plus singleflight in refreshKeys) is what prevents forged tokens with -// random `kid`s from amplifying request volume into a fetch storm against -// Keycloak. Returns errKIDRefreshCooldown when suppressed. +// unrecognized `kid`, rate-limited to at most one outbound fetch per +// unknownKIDCooldown. This runs before signature verification on +// attacker-controllable input, so the cooldown plus singleflight is what prevents +// forged tokens with random `kid`s from amplifying request volume into a fetch +// storm against Keycloak. Returns errKIDRefreshCooldown when suppressed. +// +// The cooldown gate lives *inside* the singleflight callback on purpose. A burst +// of concurrent callers with the same legitimately-rotated kid collapses into one +// in-flight fetch: the winner runs the callback while the rest block, then every +// caller shares the result and re-checks the cache (see keyFunc). Were the gate +// outside singleflight, all-but-one caller would get a suppression error and 401 +// even though the key they need is being fetched right then. The rate bound is +// unchanged: once a fetch has occurred, callbacks within the window return early +// without an outbound call, so request volume still cannot drive fetch volume. func (v *JWTValidator) refreshForUnknownKID() error { - now := time.Now().UnixNano() - last := v.lastKIDRefresh.Load() - if last != 0 && now-last < int64(unknownKIDCooldown) { - return errKIDRefreshCooldown - } - // CAS ensures only one goroutine per cooldown window proceeds to the fetch; - // losers are treated as suppressed (singleflight would dedup them anyway, but - // this also bounds the rate when fetches fail and return quickly). - if !v.lastKIDRefresh.CompareAndSwap(last, now) { - return errKIDRefreshCooldown - } - return v.refreshKeys() + _, err, _ := v.refreshGroup.Do("certs", func() (interface{}, error) { + now := time.Now().UnixNano() + last := v.lastKIDRefresh.Load() + if last != 0 && now-last < int64(unknownKIDCooldown) { + return nil, errKIDRefreshCooldown + } + // Only one callback runs per in-flight singleflight window, so a plain + // store (no CAS) is enough to stamp the window before fetching. + v.lastKIDRefresh.Store(now) + return nil, v.fetchPublicKeys() + }) + return err } func (v *JWTValidator) fetchPublicKeys() error { @@ -362,8 +410,11 @@ func (v *JWTValidator) fetchPublicKeys() error { return fmt.Errorf("failed to fetch keys: status %d", resp.StatusCode) } + // Cap the response body: a realm JWKS is a few KB, so 1 MiB is a generous + // ceiling that bounds memory if the endpoint (or something impersonating it) + // returns an unexpectedly large or unbounded body. var set jwks - if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, maxJWKSBytes)).Decode(&set); err != nil { return fmt.Errorf("failed to decode JWKS: %w", err) } @@ -386,7 +437,6 @@ func (v *JWTValidator) fetchPublicKeys() error { v.keysMu.Lock() v.publicKeys = keys - v.lastFetch = time.Now() v.keysMu.Unlock() v.logger.Info("Keycloak public keys refreshed", "count", len(keys)) diff --git a/key-manager/internal/api/jwt_validator_test.go b/key-manager/internal/api/jwt_validator_test.go index cbc6d4b..e020696 100644 --- a/key-manager/internal/api/jwt_validator_test.go +++ b/key-manager/internal/api/jwt_validator_test.go @@ -72,7 +72,6 @@ func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: &priv.PublicKey}, - lastFetch: time.Now(), } v.ready.Store(true) @@ -109,6 +108,58 @@ func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { } } +// TestRotatedKIDResolvesForConcurrentBurst is the rotation counterpart to +// TestUnknownKIDRefreshIsRateLimited. When Keycloak has rotated to a new signing +// key, a concurrent burst of legitimate requests bearing the new kid must all +// validate off a single shared JWKS fetch — none may be spuriously 401'd by the +// cooldown gate. This is the case the SPA hits on the first page load after a +// rotation, firing /api/me, /api/models and /api/keys concurrently with the same +// fresh token. The rate bound (exactly one outbound fetch) must still hold. +func TestRotatedKIDResolvesForConcurrentBurst(t *testing.T) { + oldPriv := genKey(t) + rotPriv := genKey(t) + const rotatedKID = "rotated-kid" + + var fetchCount atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetchCount.Add(1) + writeJWKS(t, w, rotatedKID, &rotPriv.PublicKey) + })) + defer srv.Close() + + // Validator starts holding only the pre-rotation key, under testKID. + v := &JWTValidator{ + logger: slog.Default(), + keycloakURL: srv.URL, + issuerURL: testIssuer, + realm: testRealm, + publicKeys: map[string]*rsa.PublicKey{testKID: &oldPriv.PublicKey}, + } + v.ready.Store(true) + + // A genuine token minted by the rotated key; its kid is not yet cached. + token := signTokenWithKID(t, rotPriv, rotatedKID, jwt.MapClaims{"preferred_username": "alice"}) + + const burst = 50 + var wg sync.WaitGroup + var okCount atomic.Int64 + for range burst { + wg.Go(func() { + if _, err := v.ValidateToken(token); err == nil { + okCount.Add(1) + } + }) + } + wg.Wait() + + if got := okCount.Load(); got != burst { + t.Fatalf("expected all %d concurrent requests with the rotated kid to validate, got %d", burst, got) + } + if got := fetchCount.Load(); got != 1 { + t.Fatalf("expected exactly 1 JWKS fetch for the rotation burst, got %d", got) + } +} + // TestAzpValidation covers the opt-in `azp` pin: when an expected client ID is // set, only tokens whose `azp` matches are accepted; when unset, `azp` is ignored. func TestAzpValidation(t *testing.T) { @@ -167,7 +218,6 @@ func TestKnownKIDNeverRefetches(t *testing.T) { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: &priv.PublicKey}, - lastFetch: time.Now(), } v.ready.Store(true) diff --git a/key-manager/internal/api/middleware_test.go b/key-manager/internal/api/middleware_test.go index 577c0dd..eff3589 100644 --- a/key-manager/internal/api/middleware_test.go +++ b/key-manager/internal/api/middleware_test.go @@ -29,7 +29,6 @@ func newTestValidator(t *testing.T, pub *rsa.PublicKey) *JWTValidator { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: pub}, - lastFetch: time.Now(), } v.ready.Store(true) return v From 2c63aac4e323265354637a4bdf787e8781c0c311 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Thu, 9 Jul 2026 15:28:47 -0400 Subject: [PATCH 14/15] Address round-2 review suggestions: interface/context, probes, DX Follow-up to the round-2 blocker fixes, taking on the non-blocking suggestions: - Derive the provisioned SPA client ID from frontend.keycloak.clientId so the operator's client, the client the SPA presents, and the key-manager azp pin can no longer be overridden apart; spaClientId is now an explicit override on top of that. Verified across all combinations with helm template. - Depend on a TokenValidator interface instead of the concrete *JWTValidator in AuthConfig, so the middleware can be tested with a fake (added TestAuthMiddlewareWithFakeValidator). - Thread context.Context through ValidateToken. The request context cancels the caller's *wait* on a JWKS fetch (via singleflight DoChan) while the fetch itself runs on a validator-lifetime context, so one cancelled request can't abort a fetch other in-flight requests are sharing. Stop() cancels that lifetime context. - Cap the JWKS fetch context and wire it to the validator lifetime; move the retry/poll/refresh knobs from package-level mutable vars to instance fields with constructor defaults (safe under t.Parallel()), and add direct coverage of the retry state machine (TestInitialFetchRetriesThenSucceeds, TestInitialFetchFallsBackToSlowPoll). - Add an unauthenticated GET /healthz to the (now API-only) key-manager, add liveness/readiness probes to both the key-manager and frontend Deployments, and add a checksum/config annotation to the frontend pod template so a config-only helm upgrade rolls the pod. Verified the checksum changes when config.json content changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/frontend-deployment.yaml | 22 +++ .../templates/key-manager-deployment.yaml | 20 +++ .../templates/key-manager-nebariapp.yaml | 13 +- charts/nebari-llm-serving/values.yaml | 10 +- key-manager/internal/api/handler.go | 14 ++ key-manager/internal/api/handler_test.go | 59 ++++--- key-manager/internal/api/jwt_validator.go | 146 ++++++++++++------ .../internal/api/jwt_validator_test.go | 97 +++++++++++- key-manager/internal/api/middleware.go | 7 +- key-manager/internal/api/middleware_test.go | 48 ++++++ 10 files changed, 350 insertions(+), 86 deletions(-) diff --git a/charts/nebari-llm-serving/templates/frontend-deployment.yaml b/charts/nebari-llm-serving/templates/frontend-deployment.yaml index 6916847..8697228 100644 --- a/charts/nebari-llm-serving/templates/frontend-deployment.yaml +++ b/charts/nebari-llm-serving/templates/frontend-deployment.yaml @@ -14,6 +14,11 @@ spec: app: {{ include "nebari-llm-serving.fullname" . }}-frontend template: metadata: + annotations: + # Roll the pod when the rendered nginx.conf / config.json changes: a + # ConfigMap update alone does not restart its consumers, so without this a + # config-only `helm upgrade` would leave the old config live in the pod. + checksum/config: {{ include (print $.Template.BasePath "/frontend-configmap.yaml") . | sha256sum }} labels: app: {{ include "nebari-llm-serving.fullname" . }}-frontend {{- include "nebari-llm-serving.selectorLabels" . | nindent 8 }} @@ -40,6 +45,23 @@ spec: readOnlyRootFilesystem: true capabilities: drop: [ALL] + # nginx serves /healthz (a static 200) for both probes. + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 resources: limits: cpu: 200m diff --git a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml index 6fced9b..021f903 100644 --- a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml @@ -78,6 +78,26 @@ spec: - name: LLM_DEV_GROUPS value: {{ .Values.keyManager.devMode.groups | join "," | quote }} {{- end }} + # Probes hit the unauthenticated /healthz, which reports only that the + # process is serving HTTP — it does not gate on the JWKS validator being + # ready, so a slow/unreachable Keycloak at startup does not CrashLoop the + # pod (per-request not-ready is surfaced to clients as 503 instead). + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 resources: limits: cpu: 200m diff --git a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml index 103a7d2..c8819aa 100644 --- a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml @@ -54,12 +54,17 @@ spec: id.token.claim: "true" access.token.claim: "true" userinfo.token.claim: "true" - # Public Keycloak client for the React SPA (PKCE, no secret). Client ID - # defaults to --spa; the operator writes it to the - # -oidc-spa-client ConfigMap. + # Public Keycloak client for the React SPA (PKCE, no secret). The client the + # operator provisions here must match the client the SPA presents + # (frontend.keycloak.clientId, rendered into config.json) and the one the + # key-manager pins `azp` to. To keep those from drifting, the client ID + # defaults to frontend.keycloak.clientId; spaClientId is only an explicit + # override on top of that. When both are empty no clientId is emitted and the + # operator generates the --spa convention — which is + # also what config.json and the azp default fall back to, so they still agree. spaClient: enabled: true - {{- with .Values.keyManager.nebariApp.auth.spaClientId }} + {{- with (.Values.keyManager.nebariApp.auth.spaClientId | default .Values.frontend.keycloak.clientId) }} clientId: {{ . | quote }} {{- end }} {{- with .Values.keyManager.nebariApp.landingPage }} diff --git a/charts/nebari-llm-serving/values.yaml b/charts/nebari-llm-serving/values.yaml index 10d3a9d..53cb1d0 100644 --- a/charts/nebari-llm-serving/values.yaml +++ b/charts/nebari-llm-serving/values.yaml @@ -138,9 +138,13 @@ keyManager: - profile - email - groups - # spaClientId - override the SPA client ID. Leave empty to use the operator - # convention --spa. Must match frontend config.json - # (frontend.keycloak.clientId), which defaults to the same convention. + # spaClientId - explicit override for the provisioned SPA client ID. Leave + # empty (the default) to derive it from frontend.keycloak.clientId so the + # provisioned client, the client the SPA presents, and the key-manager's azp + # pin can't be set apart; if that is also empty, the operator convention + # --spa is used, which config.json and the azp + # default also fall back to. Only set this to decouple the provisioned client + # from frontend.keycloak.clientId (unusual). spaClientId: "" # landingPage controls how the UI appears on the Nebari landing page. landingPage: diff --git a/key-manager/internal/api/handler.go b/key-manager/internal/api/handler.go index a980e2f..bb65a41 100644 --- a/key-manager/internal/api/handler.go +++ b/key-manager/internal/api/handler.go @@ -45,6 +45,9 @@ func NewHandler(w ModelLister, s KeyManager, logger *slog.Logger) *Handler { // RegisterRoutes registers all API routes on the given mux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // /healthz is intentionally outside /api/ so the auth middleware (which only + // wraps /api/ in main.go) does not gate it — kubelet probes carry no bearer. + mux.HandleFunc("GET /healthz", h.healthz) mux.HandleFunc("GET /api/me", h.getMe) mux.HandleFunc("GET /api/models", h.getModels) mux.HandleFunc("GET /api/keys", h.getKeys) @@ -52,6 +55,17 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("DELETE /api/keys/{namespace}/{model}/{clientID}", h.deleteKey) } +// healthz is an unauthenticated liveness/readiness probe. It reports only that +// the process is up and serving HTTP; it deliberately does not gate on the JWKS +// validator being ready. Per-request readiness is surfaced by the auth +// middleware as 503, and gating the probe on it would CrashLoop the pod whenever +// Keycloak is briefly unreachable at startup — the opposite of what we want. +func (h *Handler) healthz(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) +} + // writeJSON encodes v as JSON into w with the given status code. func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") diff --git a/key-manager/internal/api/handler_test.go b/key-manager/internal/api/handler_test.go index 1e1ef69..9892aa6 100644 --- a/key-manager/internal/api/handler_test.go +++ b/key-manager/internal/api/handler_test.go @@ -31,11 +31,11 @@ func (m *mockModelLister) FilterModelsForUser(groups []string) []models.ModelInf } type mockKeyManager struct { - keys []secrets.KeyInfo - createResult *secrets.CreateKeyResult - createErr error - revokeErr error - listUserErr error + keys []secrets.KeyInfo + createResult *secrets.CreateKeyResult + createErr error + revokeErr error + listUserErr error } func (m *mockKeyManager) CreateKey(ctx context.Context, modelName, username, description string) (*secrets.CreateKeyResult, error) { @@ -132,6 +132,21 @@ var testKeys = []secrets.KeyInfo{ }, } +// --- GET /healthz tests --- + +func TestHealthz(t *testing.T) { + h := newHandlerWithMocks(&mockModelLister{}, &mockKeyManager{}) + // No user in context: the probe must not require authentication. + rr := callHandler(t, h, http.MethodGet, "/healthz", nil, nil) + + if rr.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK) + } + if body := rr.Body.String(); body != "ok\n" { + t.Errorf("body: got %q, want %q", body, "ok\n") + } +} + // --- GET /api/me tests --- func TestGetMe(t *testing.T) { @@ -323,15 +338,15 @@ func TestCreateKey(t *testing.T) { } tests := []struct { - name string - user *UserInfo - body interface{} - accessModels []models.ModelInfo - createResult *secrets.CreateKeyResult - createErr error - wantStatus int - wantClientID string - wantAPIKey string + name string + user *UserInfo + body interface{} + accessModels []models.ModelInfo + createResult *secrets.CreateKeyResult + createErr error + wantStatus int + wantClientID string + wantAPIKey string }{ { name: "creates key and returns 201 with apiKey", @@ -442,17 +457,17 @@ func TestDeleteKey(t *testing.T) { wantStatus int }{ { - name: "returns 204 when key belongs to user", - user: testUser, - path: "/api/keys/default/llama3/user-chuck-1", - keys: testKeys, + name: "returns 204 when key belongs to user", + user: testUser, + path: "/api/keys/default/llama3/user-chuck-1", + keys: testKeys, wantStatus: http.StatusNoContent, }, { - name: "returns 403 when user doesn't own the key", - user: testUser, - path: "/api/keys/default/llama3/user-alice-1", - keys: testKeys, + name: "returns 403 when user doesn't own the key", + user: testUser, + path: "/api/keys/default/llama3/user-alice-1", + keys: testKeys, wantStatus: http.StatusForbidden, }, { diff --git a/key-manager/internal/api/jwt_validator.go b/key-manager/internal/api/jwt_validator.go index 7d0e5a7..d32b3de 100644 --- a/key-manager/internal/api/jwt_validator.go +++ b/key-manager/internal/api/jwt_validator.go @@ -20,25 +20,25 @@ import ( "golang.org/x/sync/singleflight" ) -// retryMaxAttempts and retryInitialBackoff control the *active* JWKS fetch -// retry loop run on the background init goroutine. After this budget is -// exhausted the goroutine switches to slowPollInterval to keep trying -// indefinitely without the exponential blow-up. They are package-level -// variables so tests can override them without incurring real sleep time. -var ( - retryMaxAttempts = 5 - retryInitialBackoff = 2 * time.Second - // retryDelay is called between attempts; replaced in tests to avoid sleeping. - retryDelay = time.Sleep - // slowPollInterval is the cadence for the post-retry "keep trying" loop. - slowPollInterval = 30 * time.Second - // keyRefreshInterval is the steady-state cadence at which the background +// Defaults for the JWKS fetch retry/poll cadence, applied by NewJWTValidator. +// The live values are per-instance fields on JWTValidator (not package globals), +// so tests can tune them without racing under t.Parallel(). +const ( + // defaultRetryMaxAttempts and defaultRetryInitialBackoff control the *active* + // JWKS fetch retry loop on the background init goroutine. After this budget is + // exhausted the goroutine switches to the slow poll to keep trying indefinitely + // without the exponential blow-up. + defaultRetryMaxAttempts = 5 + defaultRetryInitialBackoff = 2 * time.Second + // defaultSlowPollInterval is the cadence for the post-retry "keep trying" loop. + defaultSlowPollInterval = 30 * time.Second + // defaultKeyRefreshInterval is the steady-state cadence at which the background // goroutine proactively re-fetches the JWKS once the validator is ready, so a // key rotation is picked up without waiting for a request to miss the cache. // Kept deliberately off the request path (see refreshLoop): a synchronous // hourly refresh would stall every concurrent request for up to the fetch // timeout whenever Keycloak is unreachable at the moment the cache goes stale. - keyRefreshInterval = time.Hour + defaultKeyRefreshInterval = time.Hour ) // ErrNotReady is returned by ValidateToken when the validator's initial JWKS @@ -87,6 +87,14 @@ type jwks struct { Keys []jwk `json:"keys"` } +// TokenValidator verifies a bearer token and returns its claims. *JWTValidator +// is the production implementation (Keycloak JWKS); the middleware depends on +// this interface rather than the concrete type so tests can substitute a fake. +// The context bounds any JWKS fetch the validation may trigger. +type TokenValidator interface { + ValidateToken(ctx context.Context, tokenString string) (map[string]interface{}, error) +} + // JWTValidator validates bearer tokens minted by Keycloak against the realm's // JWKS. Under Model B (SPA-managed Keycloak) there is no gateway JWT // enforcement, so the key-manager verifies the token itself: RSA signature, @@ -124,7 +132,22 @@ type JWTValidator struct { // ready flips to true once the first JWKS fetch succeeds. It is atomic // because the writer runs on the background init goroutine while readers // run on every request-handling goroutine. - ready atomic.Bool + ready atomic.Bool + // Retry/poll cadence, defaulted in NewJWTValidator from the default* consts. + // Instance fields rather than package globals so parallel tests can tune them + // without racing. retryDelay is the sleep between active retries (swapped for a + // no-op in tests). + retryMaxAttempts int + retryInitialBackoff time.Duration + slowPollInterval time.Duration + keyRefreshInterval time.Duration + retryDelay func(time.Duration) + // baseCtx bounds outbound JWKS fetches to the validator's lifetime; cancel is + // invoked by Stop() so a shutdown aborts any in-flight fetch. Fetches use this + // context, never a request's, so a single cancelled request cannot abort a + // fetch that other in-flight requests are sharing via singleflight. + baseCtx context.Context + cancel context.CancelFunc stopCh chan struct{} doneCh chan struct{} stopOnce sync.Once @@ -140,14 +163,22 @@ func NewJWTValidator(keycloakURL, realm string, logger *slog.Logger) *JWTValidat logger = slog.Default() } cleanURL := strings.TrimSuffix(keycloakURL, "/") + baseCtx, cancel := context.WithCancel(context.Background()) v := &JWTValidator{ - logger: logger, - keycloakURL: cleanURL, - issuerURL: cleanURL, // default; override with SetIssuerURL if needed - realm: realm, - publicKeys: make(map[string]*rsa.PublicKey), - stopCh: make(chan struct{}), - doneCh: make(chan struct{}), + logger: logger, + keycloakURL: cleanURL, + issuerURL: cleanURL, // default; override with SetIssuerURL if needed + realm: realm, + publicKeys: make(map[string]*rsa.PublicKey), + retryMaxAttempts: defaultRetryMaxAttempts, + retryInitialBackoff: defaultRetryInitialBackoff, + slowPollInterval: defaultSlowPollInterval, + keyRefreshInterval: defaultKeyRefreshInterval, + retryDelay: time.Sleep, + baseCtx: baseCtx, + cancel: cancel, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), } go v.initLoop() @@ -189,22 +220,22 @@ func (v *JWTValidator) initLoop() { // fetch succeeds (marking the validator ready), or false if Stop() is called // before that happens. func (v *JWTValidator) initialFetch() bool { - backoff := retryInitialBackoff - for attempt := 1; attempt <= retryMaxAttempts; attempt++ { + backoff := v.retryInitialBackoff + for attempt := 1; attempt <= v.retryMaxAttempts; attempt++ { if v.stopped() { return false } - if err := v.fetchPublicKeys(); err == nil { + if err := v.fetchPublicKeys(v.baseCtx); err == nil { v.ready.Store(true) v.logger.Info("JWT validator ready", "attempt", attempt) return true } else { v.logger.Warn("failed to fetch Keycloak public keys, retrying", - "attempt", attempt, "maxRetries", retryMaxAttempts, "backoff", backoff, "error", err, + "attempt", attempt, "maxRetries", v.retryMaxAttempts, "backoff", backoff, "error", err, "hint", "verify LLM_KEYCLOAK_URL is correct — Keycloak 17+ does not use /auth as a context root") } - if attempt < retryMaxAttempts { - retryDelay(backoff) + if attempt < v.retryMaxAttempts { + v.retryDelay(backoff) backoff *= 2 } } @@ -212,14 +243,14 @@ func (v *JWTValidator) initialFetch() bool { // Active budget exhausted: switch to a steady slow poll so the validator // still comes online if Keycloak eventually recovers, without a tight retry // storm in the logs. - v.logger.Warn("active retry budget exhausted; switching to slow poll", "interval", slowPollInterval) + v.logger.Warn("active retry budget exhausted; switching to slow poll", "interval", v.slowPollInterval) for { select { case <-v.stopCh: return false - case <-time.After(slowPollInterval): + case <-time.After(v.slowPollInterval): } - if err := v.fetchPublicKeys(); err == nil { + if err := v.fetchPublicKeys(v.baseCtx); err == nil { v.ready.Store(true) v.logger.Info("JWT validator ready (slow poll)") return true @@ -236,14 +267,14 @@ func (v *JWTValidator) initialFetch() bool { // (fail-open on stale keys). The unknown-kid path in ValidateToken remains the // fast trigger for a rotation that lands between ticks. func (v *JWTValidator) refreshLoop() { - ticker := time.NewTicker(keyRefreshInterval) + ticker := time.NewTicker(v.keyRefreshInterval) defer ticker.Stop() for { select { case <-v.stopCh: return case <-ticker.C: - if err := v.refreshKeys(); err != nil { + if err := v.refreshKeys(v.baseCtx); err != nil { v.logger.Error("background JWKS refresh failed; keeping cached keys", "error", err) } } @@ -267,15 +298,21 @@ func (v *JWTValidator) Ready() bool { // Stop signals the background init goroutine to exit and waits for it. Safe to // call multiple times, including concurrently. func (v *JWTValidator) Stop() { - v.stopOnce.Do(func() { close(v.stopCh) }) + v.stopOnce.Do(func() { + close(v.stopCh) + v.cancel() + }) <-v.doneCh } // ValidateToken verifies a bearer token's RSA signature, expiry (with // clockLeeway) and issuer, returning the verified claims. It returns // ErrNotReady if the initial JWKS fetch has not yet completed; the caller -// should surface that as 503 Service Unavailable. -func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{}, error) { +// should surface that as 503 Service Unavailable. ctx cancels the caller's wait +// on any JWKS fetch triggered by an unknown `kid` (the fetch itself runs on the +// validator's lifetime context so one cancelled request can't abort a fetch +// shared with others). +func (v *JWTValidator) ValidateToken(ctx context.Context, tokenString string) (map[string]interface{}, error) { if !v.ready.Load() { return nil, ErrNotReady } @@ -308,7 +345,7 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} // populated by a fetch that just completed. Only a genuinely absent kid // (forged, or a bad token) falls through to the error below. See // unknownKIDCooldown / refreshForUnknownKID for why the refresh is guarded. - _ = v.refreshForUnknownKID() + _ = v.refreshForUnknownKID(ctx) v.keysMu.RLock() publicKey, exists = v.publicKeys[kid] v.keysMu.RUnlock() @@ -347,12 +384,20 @@ func (v *JWTValidator) ValidateToken(tokenString string) (map[string]interface{} // refreshKeys performs a JWKS refresh, collapsing concurrent callers into a // single in-flight outbound request via singleflight so a burst of requests -// arriving at the same time produces exactly one call to Keycloak. -func (v *JWTValidator) refreshKeys() error { - _, err, _ := v.refreshGroup.Do("certs", func() (interface{}, error) { - return nil, v.fetchPublicKeys() +// arriving at the same time produces exactly one call to Keycloak. The fetch +// runs on the validator's lifetime context; ctx only bounds how long this caller +// waits for the shared result, so a cancelled request abandons its wait without +// aborting a fetch other in-flight requests are sharing. +func (v *JWTValidator) refreshKeys(ctx context.Context) error { + ch := v.refreshGroup.DoChan("certs", func() (interface{}, error) { + return nil, v.fetchPublicKeys(v.baseCtx) }) - return err + select { + case <-ctx.Done(): + return ctx.Err() + case res := <-ch: + return res.Err + } } // refreshForUnknownKID refreshes the JWKS in response to a token carrying an @@ -370,8 +415,8 @@ func (v *JWTValidator) refreshKeys() error { // even though the key they need is being fetched right then. The rate bound is // unchanged: once a fetch has occurred, callbacks within the window return early // without an outbound call, so request volume still cannot drive fetch volume. -func (v *JWTValidator) refreshForUnknownKID() error { - _, err, _ := v.refreshGroup.Do("certs", func() (interface{}, error) { +func (v *JWTValidator) refreshForUnknownKID(ctx context.Context) error { + ch := v.refreshGroup.DoChan("certs", func() (interface{}, error) { now := time.Now().UnixNano() last := v.lastKIDRefresh.Load() if last != 0 && now-last < int64(unknownKIDCooldown) { @@ -380,15 +425,20 @@ func (v *JWTValidator) refreshForUnknownKID() error { // Only one callback runs per in-flight singleflight window, so a plain // store (no CAS) is enough to stamp the window before fetching. v.lastKIDRefresh.Store(now) - return nil, v.fetchPublicKeys() + return nil, v.fetchPublicKeys(v.baseCtx) }) - return err + select { + case <-ctx.Done(): + return ctx.Err() + case res := <-ch: + return res.Err + } } -func (v *JWTValidator) fetchPublicKeys() error { +func (v *JWTValidator) fetchPublicKeys(ctx context.Context) error { certsURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", v.keycloakURL, v.realm) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, certsURL, nil) diff --git a/key-manager/internal/api/jwt_validator_test.go b/key-manager/internal/api/jwt_validator_test.go index e020696..b9b2883 100644 --- a/key-manager/internal/api/jwt_validator_test.go +++ b/key-manager/internal/api/jwt_validator_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/rsa" "encoding/base64" "encoding/json" @@ -72,6 +73,7 @@ func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: &priv.PublicKey}, + baseCtx: context.Background(), } v.ready.Store(true) @@ -84,7 +86,7 @@ func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { for range burst { wg.Go(func() { // All of these must fail (kid never resolves); we only care about fetches. - _, _ = v.ValidateToken(forged) + _, _ = v.ValidateToken(context.Background(), forged) }) } wg.Wait() @@ -94,7 +96,7 @@ func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { } // A further attempt inside the cooldown window must not fetch again. - _, _ = v.ValidateToken(forged) + _, _ = v.ValidateToken(context.Background(), forged) if got := fetchCount.Load(); got != 1 { t.Fatalf("expected cooldown to suppress refresh, got %d fetches", got) } @@ -102,7 +104,7 @@ func TestUnknownKIDRefreshIsRateLimited(t *testing.T) { // Once the cooldown has elapsed (simulated by clearing the timestamp), a new // unknown-kid attempt is allowed to refresh again. v.lastKIDRefresh.Store(0) - _, _ = v.ValidateToken(forged) + _, _ = v.ValidateToken(context.Background(), forged) if got := fetchCount.Load(); got != 2 { t.Fatalf("expected a refresh after cooldown elapsed, got %d fetches", got) } @@ -134,6 +136,7 @@ func TestRotatedKIDResolvesForConcurrentBurst(t *testing.T) { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: &oldPriv.PublicKey}, + baseCtx: context.Background(), } v.ready.Store(true) @@ -145,7 +148,7 @@ func TestRotatedKIDResolvesForConcurrentBurst(t *testing.T) { var okCount atomic.Int64 for range burst { wg.Go(func() { - if _, err := v.ValidateToken(token); err == nil { + if _, err := v.ValidateToken(context.Background(), token); err == nil { okCount.Add(1) } }) @@ -188,7 +191,7 @@ func TestAzpValidation(t *testing.T) { if tc.azp != nil { claims["azp"] = tc.azp } - _, err := v.ValidateToken(signToken(t, priv, claims)) + _, err := v.ValidateToken(context.Background(), signToken(t, priv, claims)) if tc.wantErr && err == nil { t.Fatalf("expected error, got nil") @@ -218,13 +221,95 @@ func TestKnownKIDNeverRefetches(t *testing.T) { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: &priv.PublicKey}, + baseCtx: context.Background(), } v.ready.Store(true) - if _, err := v.ValidateToken(signToken(t, priv, jwt.MapClaims{"preferred_username": "alice"})); err != nil { + if _, err := v.ValidateToken(context.Background(), signToken(t, priv, jwt.MapClaims{"preferred_username": "alice"})); err != nil { t.Fatalf("validating a token with a cached kid: %v", err) } if got := fetchCount.Load(); got != 0 { t.Fatalf("expected no JWKS fetch for a cached kid, got %d", got) } } + +// TestInitialFetchRetriesThenSucceeds covers the active retry loop: the first two +// attempts fail and the third succeeds within the retry budget, so the validator +// comes ready without falling through to the slow poll. retryDelay is a counter +// so the test does not sleep. +func TestInitialFetchRetriesThenSucceeds(t *testing.T) { + priv := genKey(t) + var attempts atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if attempts.Add(1) < 3 { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + writeJWKS(t, w, testKID, &priv.PublicKey) + })) + defer srv.Close() + + var delays int + v := &JWTValidator{ + logger: slog.Default(), + keycloakURL: srv.URL, + issuerURL: testIssuer, + realm: testRealm, + publicKeys: map[string]*rsa.PublicKey{}, + retryMaxAttempts: 5, + retryInitialBackoff: time.Millisecond, + slowPollInterval: time.Millisecond, + retryDelay: func(time.Duration) { delays++ }, + baseCtx: context.Background(), + } + + if ok := v.initialFetch(); !ok { + t.Fatal("expected initialFetch to succeed once the server recovers") + } + if !v.Ready() { + t.Error("validator should be ready after a successful fetch") + } + if got := attempts.Load(); got != 3 { + t.Errorf("expected 3 fetch attempts (2 failures + 1 success), got %d", got) + } + if delays != 2 { + t.Errorf("expected 2 backoff delays between the 3 attempts, got %d", delays) + } +} + +// TestInitialFetchFallsBackToSlowPoll covers the state transition after the +// active retry budget is exhausted: all active attempts fail, and a later slow +// poll succeeds and marks the validator ready. +func TestInitialFetchFallsBackToSlowPoll(t *testing.T) { + priv := genKey(t) + var attempts atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if attempts.Add(1) <= 2 { // both active attempts fail + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + writeJWKS(t, w, testKID, &priv.PublicKey) + })) + defer srv.Close() + + v := &JWTValidator{ + logger: slog.Default(), + keycloakURL: srv.URL, + issuerURL: testIssuer, + realm: testRealm, + publicKeys: map[string]*rsa.PublicKey{}, + retryMaxAttempts: 2, + retryInitialBackoff: time.Millisecond, + slowPollInterval: time.Millisecond, + retryDelay: func(time.Duration) {}, + baseCtx: context.Background(), + stopCh: make(chan struct{}), + } + + if ok := v.initialFetch(); !ok { + t.Fatal("expected initialFetch to eventually succeed via the slow poll") + } + if !v.Ready() { + t.Error("validator should be ready after a slow-poll success") + } +} diff --git a/key-manager/internal/api/middleware.go b/key-manager/internal/api/middleware.go index 965caec..610595c 100644 --- a/key-manager/internal/api/middleware.go +++ b/key-manager/internal/api/middleware.go @@ -32,8 +32,9 @@ type AuthConfig struct { // GroupsClaim is the JWT claim containing groups (default: "groups"). GroupsClaim string // Validator verifies bearer tokens against Keycloak's JWKS (signature, - // expiry, issuer). Required unless DevMode is true. - Validator *JWTValidator + // expiry, issuer). Required unless DevMode is true. Typed as an interface so + // tests can substitute a fake; production uses *JWTValidator. + Validator TokenValidator // DevMode disables token handling and injects DevIdentity into every // request. It exists so the UI can run on a local cluster that has no // Keycloak in front of it. It is off by default and must never be enabled @@ -75,7 +76,7 @@ func AuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { return } - claims, err := cfg.Validator.ValidateToken(token) + claims, err := cfg.Validator.ValidateToken(r.Context(), token) if err != nil { // JWKS not fetched yet: transient, tell the client to retry. if errors.Is(err, ErrNotReady) { diff --git a/key-manager/internal/api/middleware_test.go b/key-manager/internal/api/middleware_test.go index eff3589..dacc088 100644 --- a/key-manager/internal/api/middleware_test.go +++ b/key-manager/internal/api/middleware_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/rand" "crypto/rsa" "encoding/json" @@ -29,6 +30,7 @@ func newTestValidator(t *testing.T, pub *rsa.PublicKey) *JWTValidator { issuerURL: testIssuer, realm: testRealm, publicKeys: map[string]*rsa.PublicKey{testKID: pub}, + baseCtx: context.Background(), } v.ready.Store(true) return v @@ -258,6 +260,52 @@ func TestAuthMiddlewareMisconfigured(t *testing.T) { } } +// fakeValidator is a TokenValidator stand-in: it records the context and token it +// was handed and returns canned claims, so middleware behaviour can be tested +// without a real JWKS or Keycloak. +type fakeValidator struct { + claims map[string]interface{} + err error + gotCtx context.Context + gotTok string +} + +func (f *fakeValidator) ValidateToken(ctx context.Context, token string) (map[string]interface{}, error) { + f.gotCtx = ctx + f.gotTok = token + return f.claims, f.err +} + +// TestAuthMiddlewareWithFakeValidator proves AuthConfig.Validator is satisfied by +// any TokenValidator: the middleware passes the request context and bearer token +// through and maps the returned claims onto the request identity, with no +// *JWTValidator or Keycloak involved. +func TestAuthMiddlewareWithFakeValidator(t *testing.T) { + fake := &fakeValidator{claims: map[string]interface{}{ + "preferred_username": "fakeuser", + "groups": []interface{}{"llm"}, + }} + handler := AuthMiddleware(AuthConfig{Validator: fake})(handlerThatChecksUser(t)) + req := httptest.NewRequest(http.MethodGet, "/api/keys", nil) + req.Header.Set("Authorization", "Bearer abc.def.ghi") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Header().Get("X-Username"); got != "fakeuser" { + t.Errorf("username: got %q, want %q", got, "fakeuser") + } + if fake.gotTok != "abc.def.ghi" { + t.Errorf("validator received token %q, want the bearer value", fake.gotTok) + } + if fake.gotCtx == nil { + t.Error("validator received a nil context; middleware should pass the request context through") + } +} + func TestAuthMiddlewareDevMode(t *testing.T) { devIdentity := UserInfo{ Username: "dev", From 03b27cac61435a1c439ef208713594ac97405246 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Fri, 10 Jul 2026 07:09:33 -0400 Subject: [PATCH 15/15] Implement misc suggestions. --- README.md | 2 +- .../templates/frontend-configmap.yaml | 4 +- .../templates/key-manager-deployment.yaml | 2 +- .../templates/key-manager-nebariapp.yaml | 2 +- charts/nebari-llm-serving/values.yaml | 4 +- dev/manifests/keycloak.yaml | 2 +- docs/src/content/docs/architecture.md | 2 +- docs/src/content/docs/ui-development.md | 2 +- docs/test-results/.last-run.json | 4 ++ frontend/src/auth/keycloak.ts | 12 ++++-- frontend/src/lib/api.ts | 6 +-- frontend/src/main.tsx | 40 +++++++++++++++++-- frontend/vite.config.ts | 1 - 13 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 docs/test-results/.last-run.json diff --git a/README.md b/README.md index 78481e8..8a124f2 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ The key manager web UI is a [React](https://react.dev) + TypeScript app (Vite, T cd dev && make run-dev ``` -This idempotently brings up the kind cluster, deploys the operator and a **dev-mode** key manager (auth bypassed, a fixed `dev` identity injected — see `LLM_DEV_MODE`), applies a few OpenRouter passthrough models so the list is populated, port-forwards the key-manager API to `localhost:8080`, and starts the Vite dev server at **http://localhost:5173** with hot reload. Edit files under `frontend/src/` and the browser updates live — there is no build step and no login. Press `Ctrl-C` to stop (the cluster is left running for the next run). +This idempotently brings up the kind cluster, deploys the operator and a **dev-mode** key manager (auth bypassed, a fixed `dev` identity injected - see `LLM_DEV_MODE`), applies a few OpenRouter passthrough models so the list is populated, port-forwards the key-manager API to `localhost:8080`, and starts the Vite dev server at **http://localhost:5173** with hot reload. Edit files under `frontend/src/` and the browser updates live - there is no build step and no login. Press `Ctrl-C` to stop (the cluster is left running for the next run). Requires [Node.js](https://nodejs.org) + npm and an [OpenRouter API key](https://openrouter.ai/keys). To exercise the real Keycloak login flow instead of the bypass, deploy Keycloak (`make deploy-keycloak && make pf-keycloak`) and run the UI without the dev flag (`cd frontend && npm run dev`). diff --git a/charts/nebari-llm-serving/templates/frontend-configmap.yaml b/charts/nebari-llm-serving/templates/frontend-configmap.yaml index 91f3be7..8c59afe 100644 --- a/charts/nebari-llm-serving/templates/frontend-configmap.yaml +++ b/charts/nebari-llm-serving/templates/frontend-configmap.yaml @@ -66,7 +66,7 @@ data: # sets its own add_header, so these cover the SPA document (location # /); the config.json block below repeats the ones that matter for it. # The framing headers matter because the UI exposes one-click "revoke - # key" — deny embedding to block clickjacking. + # key" - deny embedding to block clickjacking. add_header X-Frame-Options "DENY" always; add_header Content-Security-Policy "frame-ancestors 'none'" always; add_header X-Content-Type-Options "nosniff" always; @@ -77,7 +77,7 @@ data: image/svg+xml; gzip_min_length 1024; - # Proxy /api/* to the key-manager ClusterIP — never exposed publicly. + # Proxy /api/* to the key-manager ClusterIP - never exposed publicly. location /api/ { proxy_pass http://{{ include "nebari-llm-serving.fullname" . }}-key-manager.{{ include "nebari-llm-serving.operatorNamespace" . }}.svc.cluster.local:8080/api/; proxy_set_header Authorization $http_authorization; diff --git a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml index 021f903..6f994dc 100644 --- a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml @@ -79,7 +79,7 @@ spec: value: {{ .Values.keyManager.devMode.groups | join "," | quote }} {{- end }} # Probes hit the unauthenticated /healthz, which reports only that the - # process is serving HTTP — it does not gate on the JWKS validator being + # process is serving HTTP - it does not gate on the JWKS validator being # ready, so a slow/unreachable Keycloak at startup does not CrashLoop the # pod (per-request not-ready is surfaced to clients as 503 instead). livenessProbe: diff --git a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml index c8819aa..925f95b 100644 --- a/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml @@ -60,7 +60,7 @@ spec: # key-manager pins `azp` to. To keep those from drifting, the client ID # defaults to frontend.keycloak.clientId; spaClientId is only an explicit # override on top of that. When both are empty no clientId is emitted and the - # operator generates the --spa convention — which is + # operator generates the --spa convention - which is # also what config.json and the azp default fall back to, so they still agree. spaClient: enabled: true diff --git a/charts/nebari-llm-serving/values.yaml b/charts/nebari-llm-serving/values.yaml index 53cb1d0..03eceba 100644 --- a/charts/nebari-llm-serving/values.yaml +++ b/charts/nebari-llm-serving/values.yaml @@ -131,7 +131,7 @@ keyManager: auth: # Model B (SPA-managed Keycloak). The operator provisions a public PKCE # SPA client and (because enforceAtGateway is false in the template) emits - # no gateway SecurityPolicy — keycloak-js logs in from the browser and the + # no gateway SecurityPolicy - keycloak-js logs in from the browser and the # key-manager validates the bearer token against Keycloak's JWKS. scopes: - openid @@ -181,7 +181,7 @@ frontend: # versions move together. Override to pull a specific build. tag: "" pullPolicy: Always - # port nginx listens on. Must be > 1024 — the container runs as non-root + # port nginx listens on. Must be > 1024 - the container runs as non-root # (UID 101) with all capabilities dropped and cannot bind privileged ports. port: 8080 service: diff --git a/dev/manifests/keycloak.yaml b/dev/manifests/keycloak.yaml index 6c8a94b..de82eab 100644 --- a/dev/manifests/keycloak.yaml +++ b/dev/manifests/keycloak.yaml @@ -9,7 +9,7 @@ # Reach it from the host with: # kubectl -n keycloak port-forward svc/keycloak 8180:8080 # which matches frontend/public/config.json (url: http://localhost:8180, -# realm: nebari, clientId: nebari-frontend-spa). Login as testuser / testuser. +# realm: nebari, clientId: nebari-frontend-spa). Login as dev / password. apiVersion: apps/v1 kind: Deployment metadata: diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index f779963..8cd3444 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -368,7 +368,7 @@ spec: - pathPrefix: / auth: enabled: true - provider: keycloak # or generic-oidc + provider: keycloak enforceAtGateway: false # SPA drives login; no gateway cookie/oauth2-proxy scopes: [openid, profile, email, groups] keycloakConfig: diff --git a/docs/src/content/docs/ui-development.md b/docs/src/content/docs/ui-development.md index bb2078c..d4bc336 100644 --- a/docs/src/content/docs/ui-development.md +++ b/docs/src/content/docs/ui-development.md @@ -25,7 +25,7 @@ You develop against `http://localhost:5173`. ## Prerequisites - Docker, [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation), kubectl, helm, and Go 1.25+. -- [Node.js](https://nodejs.org) 20+ and npm (for the Vite dev server). +- [Node.js](https://nodejs.org) 22+ and npm (for the Vite dev server). - An [OpenRouter API key](https://openrouter.ai/keys). ## Setup diff --git a/docs/test-results/.last-run.json b/docs/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/docs/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/frontend/src/auth/keycloak.ts b/frontend/src/auth/keycloak.ts index a9dfeac..d1dd385 100644 --- a/frontend/src/auth/keycloak.ts +++ b/frontend/src/auth/keycloak.ts @@ -89,14 +89,20 @@ export class SessionExpiredError extends Error { } } -/** Returns a valid access token, refreshing it first if it is close to expiry. */ -export async function getToken(): Promise { +/** + * Returns a valid access token, refreshing it first if it is close to expiry. + * Pass `forceRefresh` to refresh unconditionally (used by the 401 retry, where + * the current token was just rejected and re-sending it would only 401 again). + */ +export async function getToken(forceRefresh = false): Promise { if (!_keycloak?.authenticated) { throw new SessionExpiredError(); } try { - await _keycloak.updateToken(30); + // updateToken(-1) forces a refresh; updateToken(30) is a no-op unless the + // token has under 30s of validity left. + await _keycloak.updateToken(forceRefresh ? -1 : 30); } catch { _keycloak.login(); throw new SessionExpiredError(); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 38a2e78..498f8fa 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -14,8 +14,8 @@ export class ApiError extends Error { async function request(method: string, path: string, body?: unknown): Promise { // Auth is SPA-managed Keycloak: attach the current access token as a bearer // on every call. getToken() refreshes it first if it is near expiry. - const exec = async () => { - const token = await getToken(); + const exec = async (forceRefresh = false) => { + const token = await getToken(forceRefresh); const opts: RequestInit = { method, headers: { @@ -33,7 +33,7 @@ async function request(method: string, path: string, body?: unknown): Promise // 401, force a refresh and retry once. let resp = await exec(); if (resp.status === 401) { - resp = await exec(); + resp = await exec(true); } if (!resp.ok) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7cbb90d..69c8f87 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { getAppConfig } from "@/app/config"; import { initKeycloak } from "@/auth/keycloak"; import { queryClient } from "@/lib/queryClient"; import { ThemeProvider } from "@/providers/ThemeProvider"; @@ -10,14 +11,45 @@ import App from "./App.tsx"; import "./index.css"; +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +// Renders a plain, dependency-free message so a bootstrap failure (typically a +// malformed or unreachable /config.json) shows something actionable instead of +// a blank white page. +function renderBootstrapError(container: HTMLElement, message: string) { + container.textContent = ""; + const wrapper = document.createElement("div"); + wrapper.setAttribute("role", "alert"); + wrapper.style.cssText = + "max-width:40rem;margin:4rem auto;padding:0 1.5rem;font-family:system-ui,sans-serif;line-height:1.5"; + const heading = document.createElement("h1"); + heading.textContent = "Unable to start"; + const detail = document.createElement("p"); + detail.textContent = message; + wrapper.append(heading, detail); + container.append(wrapper); +} + // Authenticate before rendering. initKeycloak() loads /config.json and runs the // Keycloak login-required flow, only resolving once the user is signed in (or // immediately, via the dev/E2E bypass). -await initKeycloak(); +try { + await initKeycloak(); +} catch (err) { + renderBootstrapError( + rootElement, + "The app could not load its runtime configuration or reach the login service. Check that /config.json is valid and Keycloak is reachable, then reload.", + ); + throw err; +} -const rootElement = document.getElementById("root"); -if (!rootElement) { - throw new Error("Root element not found"); +// Optional page-title override from /config.json (frontend.title in the chart). +const configuredTitle = getAppConfig()?.title; +if (configuredTitle) { + document.title = configuredTitle; } createRoot(rootElement).render( diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4544669..3e41dcd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -27,7 +27,6 @@ export default defineConfig(({ mode }) => { server: { proxy: { "/api": { target: apiTarget, changeOrigin: true }, - "/logout": { target: apiTarget, changeOrigin: true }, }, },