diff --git a/.gitignore b/.gitignore index 09cb1be..318991d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ docs/drizzle-libsql-reference.md config/ .worktrees/ *.bak +test-results/ diff --git a/Caddyfile b/Caddyfile index 9ab5ca3..1de69b1 100644 --- a/Caddyfile +++ b/Caddyfile @@ -3,6 +3,7 @@ # In dev mode, config.json is proxied to Vite along with all other frontend requests (dev-frontend) { + header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws://vite:5173 ws://localhost:5173; font-src 'self'; frame-ancestors 'none'" reverse_proxy vite:5173 } diff --git a/Dockerfile.runtime b/Dockerfile.runtime index 4ad044b..bddbf58 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -15,7 +15,8 @@ RUN if [ -f runtime/src/mastra/index.ts ]; then \ FROM node:22-alpine WORKDIR /app COPY --from=builder /app/output/ ./ +COPY runtime/init-db.mjs ./init-db.mjs EXPOSE 4111 ENV PORT=4111 NODE_ENV=production USER node -CMD ["node", "index.mjs"] +CMD ["sh", "-c", "node init-db.mjs && node index.mjs"] diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index c514aaf..3ff14b8 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -9,10 +9,10 @@ |----|------|-------|---------|-------------| | SPEC-20260407-001 | infra-docker-k8s-init | CODE | 2026-04-07 | Infrastructure: Docker Compose (9 containers), K8s scaffolding, documentation templates | | SPEC-20260408-001 | docker-compose-architecture-alignment | CODE | 2026-04-08 | Docker Compose update: two networks, dev/prod mode, Caddyfile env switching, config.json | -| SPEC-20260409-001 | better-auth-drizzle-libsql-auth | QA | 2026-04-09 | Better Auth + Drizzle/LibSQL: schema, Better Auth instance, drizzle-kit push, Zod schemas, route mounting in Mastra | +| SPEC-20260409-001 | db-schema-drizzle-integration | SPEC | 2026-04-09 | Drizzle ORM + Better Auth + wiki vectors + local tickets DB layer | | SPEC-20260408-002 | linear-resend-integration-tools | CODE | 2026-04-08 | Linear ticketing tools, Resend email tools, shared schemas, and runtime integration tests | | SPEC-20260408-003 | langfuse-observability-integration | CODE | 2026-04-08 | OpenRouter Broadcast + Langfuse SDK fallback, Cloudflare tunnel, trace verification | -| SPEC-20260409-001 | db-schema-drizzle-integration | SPEC | 2026-04-09 | Drizzle ORM + Better Auth + wiki vectors + local tickets DB layer | +| SPEC-20260409-002 | fe-auth-pages-login-register | COMPLETE | 2026-04-09 | Auth pages (login/register), Better Auth client SDK, auth guard, logout, neumorphic UX — 33 E2E tests passing | ## Integration Test Status @@ -31,4 +31,4 @@ _No completed specs._ ## Archived Specs -_No archived specs._ \ No newline at end of file +_No archived specs._ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 4e52541..67401cc 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -7,18 +7,18 @@ services: vite: image: node:22-alpine working_dir: /app - command: npx vite --host 0.0.0.0 + command: sh -c "npm install && npx vite --host 0.0.0.0" ports: - "127.0.0.1:5173:5173" volumes: - ./frontend:/app - - /app/node_modules + - vite_node_modules:/app/node_modules networks: - app frontend: environment: - FRONTEND_MODE: dev + FRONTEND_MODE: static runtime: build: @@ -26,6 +26,14 @@ services: dockerfile: Dockerfile.runtime target: builder working_dir: /app/runtime - command: npx mastra dev --port 4111 + command: sh -c "sed -i 's|LIBSQL_URL=.*|LIBSQL_URL=http://libsql:8080|' /app/runtime/.env && node /app/runtime/init-db.mjs && npx mastra dev" + environment: + - PORT=4111 + - LIBSQL_URL=http://libsql:8080 + - NODE_ENV=development volumes: - ./runtime/src:/app/runtime/src + - ./runtime/init-db.mjs:/app/runtime/init-db.mjs + +volumes: + vite_node_modules: diff --git a/docs/FE-03-auth-pages-handoff.md b/docs/FE-03-auth-pages-handoff.md new file mode 100644 index 0000000..e63a98e --- /dev/null +++ b/docs/FE-03-auth-pages-handoff.md @@ -0,0 +1,116 @@ +# FE-03 — Auth Pages (Login/Register) — Implementation Handoff + +**Branch:** `feature/fe-auth-pages` +**Spec:** SPEC-20260409-002 +**Ticket:** FE-03 — Auth Pages (Login/Register) +**Epic:** Frontend | **Tier:** 1 (Must Ship) | **Estimate:** 2h +**Date:** 2026-04-09 + +--- + +## Ticket Acceptance Criteria — Status + +| # | Criterion | Status | Evidence | +|---|-----------|--------|----------| +| 1 | User can register with email/password | ✅ Done | `/register` page → `signUp.email()` → auto-login → redirect | +| 2 | User can login and be redirected to chat | ✅ Done | `/login` page → `signIn.email()` → redirect to `/chat` | +| 3 | Unauthenticated access redirects to login | ✅ Done | Auth guard in `__root.tsx` — all routes protected | + +## Ticket Requirements — Status + +| Requirement | Status | Details | +|------------|--------|---------| +| Email/password form | ✅ Done | Both login and register pages with validated forms | +| Better Auth client SDK integration | ✅ Done | `auth-client.ts` with `createAuthClient`, basePath `/auth` | +| Redirect to chat after login | ✅ Done | `navigate({ to: "/chat" })` on success | +| Protected routes | ✅ Done | Root layout auth guard with `` | +| Clean, minimal design with shadcn/ui | ✅ Done | Neumorphic cards, navy bg, orange CTA, Space Grotesk headings | +| Dependency: INFRA-02 (Better Auth backend) | ✅ Met | Backend auth at `/auth/*` operational with LibSQL persistence | + +## What Was Built + +### Frontend (6 files) + +| File | What It Does | +|------|-------------| +| `frontend/src/lib/auth-client.ts` | Better Auth client SDK singleton. baseURL from `window.location.origin`, basePath `/auth`. Exports `signIn`, `signUp`, `signOut`. | +| `frontend/src/hooks/use-auth.ts` | `useAuth()` hook wrapping Better Auth's `useSession()`. Returns `{ user, isLoading, isAuthenticated }`. Re-exports `signIn`, `signUp`, `signOut`. | +| `frontend/src/routes/login.tsx` | Login page at `/login`. Email+password form, error handling (API errors, network errors, fallback messages), loading state, redirect if authenticated. | +| `frontend/src/routes/register.tsx` | Register page at `/register`. Name+email+password form, duplicate email detection, password error passthrough, loading state, redirect if authenticated. | +| `frontend/src/routes/__root.tsx` | Root layout with auth guard. Loading spinner → unauthenticated redirect → auth page bypass → authenticated layout with sidebar (nav links, user info, theme toggle, logout button). | +| `frontend/vite.config.ts` | Dev proxy: `/auth` → backend:4111, `/chat` POST-only → backend:4111 (GET serves SPA). | + +### Backend (1 file modified) + +| File | Change | +|------|--------| +| `runtime/src/auth/index.ts` | Added `localhost:3002` to dev trusted origins for SSH port-forwarded testing. | + +### Infrastructure (1 file modified) + +| File | Change | +|------|--------| +| `docker-compose.override.yml` | Fixed `mastra dev --port 4111` → `PORT=4111` env var (mastra CLI dropped `--port` flag). | + +### Tests (E2E with Playwright) + +| File | Tests | What It Covers | +|------|-------|----------------| +| `tests/fe-auth-pages/auth-e2e.e2e.ts` | 33 | Full E2E against running stack: health checks, auth guard redirects, login/register rendering, real sign-up/sign-in flows, error handling, sidebar verification, authenticated redirects, proxy integration, page navigation | +| `playwright.config.ts` | — | Playwright config: chromium headless, baseURL localhost:3001 | + +**All 33 E2E tests pass against the real stack (Vite + Mastra + Better Auth + LibSQL). Zero mocks.** + +## Auth Flow + +``` +Unauthenticated user → any route → auth guard → redirect /login + ↓ + /login ← fill form → signIn.email() + ↓ + success → redirect /chat → sidebar with user info + error → show alert (invalid creds / network error) + +New user → /register → fill form → signUp.email() + ↓ + success → auto-login → redirect /chat + error → "already exists" / password error / network error + +Authenticated user → /login or /register → redirect to /chat +Authenticated user → click logout → signOut() → redirect /login +``` + +## How to Run + +```bash +# Start the stack +cd runtime && PORT=4111 npx mastra dev & +cd frontend && npx vite --port 3001 --host & + +# Run E2E tests +npm run test:e2e:auth + +# Or run all E2E tests +npm run test:e2e +``` + +Requires LibSQL running on `:8080` (via docker-compose or direct). + +## Commits on This Branch + +| Hash | Message | +|------|---------| +| `5218fc1` | feat(auth): auth client SDK, useAuth hook, vite proxy | +| `2ba6bee` | feat(auth): register page + auth guard activation | +| `f9facf0` | feat(auth): login page with neumorphic UX | +| `0bc2e3a` | test(auth): auth guard tests | +| `8d58853` | qa(auth): QA report GO — 53/53 tests, 6/6 REQs | +| `eda6eca` | test(auth): rewrite tests as E2E with Playwright — 33/33 | +| `621dc7c` | fix(auth): logout button, /chat proxy bypass, dev trusted origin | + +## Known Limitations + +- No password confirmation field on register (acceptable for hackathon) +- No forgot password flow (out of scope per spec) +- No OAuth/social login (out of scope per spec) +- Spec says `max-w-sm` for cards, implementation uses `max-w-md` (cosmetic) diff --git a/docs/FE-03-docker-infra-fixes-handoff.md b/docs/FE-03-docker-infra-fixes-handoff.md new file mode 100644 index 0000000..b11149e --- /dev/null +++ b/docs/FE-03-docker-infra-fixes-handoff.md @@ -0,0 +1,110 @@ +# FE-03 — Docker Infrastructure Fixes — Handoff + +**Branch:** `feature/fe-auth-pages` +**Date:** 2026-04-09 +**Context:** After merging dev (`5ac0512`), the Docker environment was broken — runtime crashed on startup, frontend served stale builds, auth tables didn't exist. These fixes make `docker compose up --build` work out of the box. + +--- + +## Summary of Issues & Fixes + +### 1. Runtime crash: LibSQL ECONNREFUSED (localhost:8080) + +**Root cause:** `mastra dev` uses dotenv to load `runtime/.env`, which contains `LIBSQL_URL=http://localhost:8080` (for local dev without Docker). This overrode the Docker-injected env var `http://libsql:8080`. + +**Fix (docker-compose.override.yml):** `sed` rewrites `LIBSQL_URL` in the baked-in `.env` before `mastra dev` starts. Also sets `LIBSQL_URL=http://libsql:8080` explicitly in the environment block. + +### 2. Database tables missing: "no such table: auth_user" + +**Root cause:** Fresh LibSQL container has no tables. `drizzle-kit push` requires interactive TTY (unsuitable for Docker). + +**Fix:** Created `runtime/init-db.mjs` — runs `CREATE TABLE IF NOT EXISTS` for all 7 tables (auth_user, auth_session, auth_account, auth_verification, wiki_documents, wiki_chunks, local_tickets). Executes before the server starts in both dev and production Docker. + +### 3. Vite container crash: ERR_MODULE_NOT_FOUND + +**Root cause:** Anonymous volume `/app/node_modules` was empty with no `npm install` step. + +**Fix (docker-compose.override.yml):** Changed Vite command to `sh -c "npm install && npx vite --host 0.0.0.0"` and switched to a named volume `vite_node_modules`. + +### 4. CSP blocks Vite inline scripts in dev mode + +**Root cause:** Caddy's `Content-Security-Policy` header had `script-src 'self'`, blocking Vite's HMR preamble injection (`@vitejs/plugin-react can't detect preamble`). + +**Fix (Caddyfile):** Dev-frontend snippet overrides CSP to allow `'unsafe-inline'` and `'unsafe-eval'` for scripts, plus `ws://` for Vite HMR WebSocket. + +### 5. Duplicate import in mastra/index.ts + +**Root cause:** Merge conflict left duplicate `import { registerApiRoute }` on lines 2 and 5, causing `mastra build` to fail. + +**Fix:** Removed the duplicate import line. + +### 6. Auth 403 "invalid origin" via SSH tunnel + +**Root cause:** Better Auth `trustedOrigins` only included `localhost:3001`. SSH port-forward testing uses `localhost:3002`. Also, `NODE_ENV=production` was set by Dockerfile even in dev override (via `target: builder` inheriting base image state). + +**Fix:** Added `http://localhost:3002` to dev trustedOrigins in `runtime/src/lib/auth.ts`. Added `NODE_ENV=development` to docker-compose.override.yml environment. + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `runtime/init-db.mjs` | **New.** Creates all 7 DB tables idempotently on startup. | +| `Dockerfile.runtime` | Copies `init-db.mjs` into production image. CMD runs `node init-db.mjs && node index.mjs`. | +| `docker-compose.override.yml` | Vite: `npm install` + named volume. Runtime: `sed` fix for .env, `LIBSQL_URL` + `NODE_ENV=development` env vars, init-db mount. Frontend: `FRONTEND_MODE: static`. | +| `Caddyfile` | Dev-frontend snippet: relaxed CSP for Vite HMR (unsafe-inline, unsafe-eval, ws://). | +| `runtime/src/lib/auth.ts` | Added `localhost:3002` to dev trustedOrigins for SSH tunnel testing. | +| `runtime/src/mastra/index.ts` | Removed duplicate `registerApiRoute` import (merge artifact). | + +## Architecture Notes + +### Dev mode (docker-compose.override.yml auto-loaded) + +``` +Browser → Caddy:3001 (static files from /srv/) → runtime:4111 (/api/*, /auth/*) + ↕ + LibSQL:8080 + +Runtime runs: sed .env → init-db.mjs → mastra dev (hot reload) +``` + +### Production mode (`docker compose -f docker-compose.yml up --build`) + +``` +Browser → Caddy:3001 (static files from /srv/) → runtime:4111 (/api/*, /auth/*) + ↕ + LibSQL:8080 + +Runtime runs: node init-db.mjs && node index.mjs +``` + +### SSH tunnel testing + +``` +Local browser:3002 → SSH tunnel → Server:3001 → Caddy → runtime:4111 +``` + +Requires `http://localhost:3002` in Better Auth trustedOrigins (dev only). + +## How to Run + +```bash +# Dev mode (default — auto-loads override) +docker compose up --build + +# Production mode (no override, no Vite, no hot reload) +docker compose -f docker-compose.yml up --build + +# SSH tunnel from local machine +ssh -L 3002:localhost:3001 agent@ +# Then open http://localhost:3002 +``` + +## Verified Auth Flow (E2E in Docker) + +1. Navigate to `/` → auth guard redirects to `/login` +2. Click "Register" → fill form → Create Account → redirects to `/chat` with user info in sidebar +3. Click "Sign out" → redirects to `/login` +4. Fill login form → Log In → redirects to `/chat` +5. All tested via Playwright (server-side) and manual SSH tunnel (client-side) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec76383..97f82b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.10", "ai": "^6.0.153", + "better-auth": "^1.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", @@ -491,6 +492,147 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.1.tgz", + "integrity": "sha512-HnRQEuuCT7tIc2GMuRGqudt0DNO8+LhoR3O1heY/nYMxbxPVOBm/f4UQFXeb5HYcHpDrwTO+woWpp318OQ++Uw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.1.tgz", + "integrity": "sha512-DgFDG8emuBxHYHA1xfUSCtMExsxOw6W3A1AVLA8TzYIg4qjkfDXcnI40ZPkDfsdnYm2WwQgJYRWP5KXlESakGA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.1", + "@better-auth/utils": "0.4.0", + "drizzle-orm": ">=0.41.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.1.tgz", + "integrity": "sha512-P3X/nTpY5EoBD9HEF5I4gTCGL3lf7wIlzdQweL1+WIlJK0yw6jaSqyne82PvgbLs63BLKODrQ8KNy9yrQKwPBw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.1", + "@better-auth/utils": "0.4.0", + "kysely": "^0.27.0 || ^0.28.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.1.tgz", + "integrity": "sha512-avyECOAv5LsSlIPoQpM++iMlc34YRde/B8klfbJpnqFE25NXtDTyxlv0xkp+RWETWqrnHs4vC+rLePylK7LwGg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.1", + "@better-auth/utils": "0.4.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.1.tgz", + "integrity": "sha512-ijK+5eePMV5Vgy88lvUgn0eP5jeWvV5wYPtpFFTu+n+jCpe5VEcqlpTdYiSP3s3MhW9QucoNg08ov0hhaiM83Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.1", + "@better-auth/utils": "0.4.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.1.tgz", + "integrity": "sha512-KqNHKHFQM+Pc2SF/k5DbVAUzCXZqMIDx4QpFAmDt3Wo0tzvGl12g6/ph8bOM8LKScZAVPpkm3z+NRDbh5sKuuw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.1", + "@better-auth/utils": "0.4.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.1.tgz", + "integrity": "sha512-UrY49vZ7RHvMlsVlivgvIsCECdl2DAQfmqI8aymY0GMWE6MjukrZ3iS+L+txKWrV7I8vt5itc468ntR1PuvbKw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.1", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.60.2", "license": "BSD-3-Clause", @@ -1554,6 +1696,15 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "license": "MIT" @@ -4589,6 +4740,155 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.1.tgz", + "integrity": "sha512-rXxR5G0xNezCiwlxdvU5L0BsYvlddvGfL61azo1w8IFrqck0jPOuE9zU28ZFJpQjfTNwXhWp0ddEqxY7zVZxmQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.1", + "@better-auth/drizzle-adapter": "1.6.1", + "@better-auth/kysely-adapter": "1.6.1", + "@better-auth/memory-adapter": "1.6.1", + "@better-auth/mongo-adapter": "1.6.1", + "@better-auth/prisma-adapter": "1.6.1", + "@better-auth/telemetry": "1.6.1", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.5", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.14", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-call": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", + "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5198,6 +5498,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -6638,6 +6944,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.28.15", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.15.tgz", + "integrity": "sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -8019,6 +8334,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", + "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -9037,6 +9367,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "license": "MIT", @@ -9167,6 +9503,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "license": "ISC" diff --git a/frontend/package.json b/frontend/package.json index b4f1509..b4fe730 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,17 +17,18 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", + "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.10", "ai": "^6.0.153", + "better-auth": "^1.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "@tailwindcss/typography": "^0.5.16", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "shadcn": "^4.2.0", diff --git a/frontend/src/hooks/use-auth.ts b/frontend/src/hooks/use-auth.ts index 2b27f12..6df1e04 100644 --- a/frontend/src/hooks/use-auth.ts +++ b/frontend/src/hooks/use-auth.ts @@ -1,10 +1,8 @@ /** - * Better Auth client hook. - * Chenko will implement this as part of TRI-4 (Better Auth Setup) - * and TRI-21 (Auth Pages). - * - * This placeholder exports the hook shape so routes can reference it. + * Better Auth session hook. + * Wraps authClient.useSession() from Better Auth React SDK. */ +import { authClient, signIn, signUp, signOut } from "@/lib/auth-client" export interface User { id: string @@ -19,11 +17,21 @@ export interface AuthState { } export function useAuth(): AuthState { - // TODO: Replace with Better Auth client SDK - // import { createAuthClient } from "better-auth/react" + const session = authClient.useSession() + + const user: User | null = session.data?.user + ? { + id: session.data.user.id, + email: session.data.user.email, + name: session.data.user.name, + } + : null + return { - user: null, - isLoading: false, - isAuthenticated: false, + user, + isLoading: session.isPending, + isAuthenticated: !!session.data?.user, } } + +export { signIn, signUp, signOut } diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts new file mode 100644 index 0000000..6da0e34 --- /dev/null +++ b/frontend/src/lib/auth-client.ts @@ -0,0 +1,15 @@ +/** + * Better Auth client SDK — configured to match backend auth at /auth. + * In dev mode, Vite proxy forwards /auth/* to localhost:4111. + */ +import { createAuthClient } from "better-auth/react" + +export const authClient = createAuthClient({ + baseURL: + typeof window !== "undefined" + ? window.location.origin + : "http://localhost:3001", + basePath: "/auth", +}) + +export const { signIn, signUp, signOut } = authClient diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e2cafc7..443aa7a 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -11,6 +11,8 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRouteImport } from './routes/__root' +import { Route as RegisterRouteImport } from './routes/register' +import { Route as LoginRouteImport } from './routes/login' import { Route as ChatRouteImport } from './routes/chat' import { Route as IndexRouteImport } from './routes/index' @@ -27,6 +29,16 @@ const BoardLazyRoute = BoardLazyRouteImport.update({ path: '/board', getParentRoute: () => rootRouteImport, } as any).lazy(() => import('./routes/board.lazy').then((d) => d.Route)) +const RegisterRoute = RegisterRouteImport.update({ + id: '/register', + path: '/register', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/chat', path: '/chat', @@ -41,12 +53,16 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/chat': typeof ChatRoute + '/login': typeof LoginRoute + '/register': typeof RegisterRoute '/board': typeof BoardLazyRoute '/settings': typeof SettingsLazyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/chat': typeof ChatRoute + '/login': typeof LoginRoute + '/register': typeof RegisterRoute '/board': typeof BoardLazyRoute '/settings': typeof SettingsLazyRoute } @@ -54,20 +70,31 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/chat': typeof ChatRoute + '/login': typeof LoginRoute + '/register': typeof RegisterRoute '/board': typeof BoardLazyRoute '/settings': typeof SettingsLazyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/chat' | '/board' | '/settings' + fullPaths: '/' | '/chat' | '/login' | '/register' | '/board' | '/settings' fileRoutesByTo: FileRoutesByTo - to: '/' | '/chat' | '/board' | '/settings' - id: '__root__' | '/' | '/chat' | '/board' | '/settings' + to: '/' | '/chat' | '/login' | '/register' | '/board' | '/settings' + id: + | '__root__' + | '/' + | '/chat' + | '/login' + | '/register' + | '/board' + | '/settings' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ChatRoute: typeof ChatRoute + LoginRoute: typeof LoginRoute + RegisterRoute: typeof RegisterRoute BoardLazyRoute: typeof BoardLazyRoute SettingsLazyRoute: typeof SettingsLazyRoute } @@ -88,6 +115,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BoardLazyRouteImport parentRoute: typeof rootRouteImport } + '/register': { + id: '/register' + path: '/register' + fullPath: '/register' + preLoaderRoute: typeof RegisterRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/chat': { id: '/chat' path: '/chat' @@ -108,6 +149,8 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ChatRoute: ChatRoute, + LoginRoute: LoginRoute, + RegisterRoute: RegisterRoute, BoardLazyRoute: BoardLazyRoute, SettingsLazyRoute: SettingsLazyRoute, } diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index dcfb51b..bd4934b 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,7 +1,7 @@ -import { createRootRoute, Link, Outlet, useLocation } from "@tanstack/react-router" +import { createRootRoute, Link, Navigate, Outlet, useLocation } from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/router-devtools" -import { MessageSquare, LayoutGrid, Settings, Sun, Moon } from "lucide-react" -import { useAuth } from "@/hooks/use-auth" +import { MessageSquare, LayoutGrid, Settings, Sun, Moon, LogOut } from "lucide-react" +import { useAuth, signOut } from "@/hooks/use-auth" import { useTheme } from "@/components/theme-provider" export const Route = createRootRoute({ @@ -9,7 +9,7 @@ export const Route = createRootRoute({ }) function RootLayout() { - const { /* isAuthenticated, isLoading */ } = useAuth() + const { isAuthenticated, isLoading, user } = useAuth() const { resolvedTheme, setTheme } = useTheme() const location = useLocation() @@ -17,18 +17,21 @@ function RootLayout() { const isAuthPage = location.pathname === "/login" || location.pathname === "/register" - // TODO: Uncomment when Better Auth is connected (TRI-4 + TRI-21) - // if (isLoading) { - // return ( - //
- //
- //
- // ) - // } - // - // if (!isAuthenticated && !isAuthPage) { - // return - // } + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!isAuthenticated && !isAuthPage) { + return + } + + if (isAuthenticated && isAuthPage) { + return + } // Auth pages render without sidebar if (isAuthPage) { @@ -83,12 +86,22 @@ function RootLayout() {
- K + {user?.name?.[0]?.toUpperCase() || "?"}
-

Koki

-

Developer

+

{user?.name || "User"}

+

{user?.email || ""}

+
diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 0000000..330913c --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,123 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { useState } from "react" +import { useAuth } from "@/hooks/use-auth" +import { authClient } from "@/lib/auth-client" + +export const Route = createFileRoute("/login")({ + component: LoginPage, +}) + +function LoginPage() { + const { isAuthenticated } = useAuth() + const navigate = useNavigate() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Redirect authenticated users to /chat + if (isAuthenticated) { + navigate({ to: "/chat" }) + return null + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setIsSubmitting(true) + + try { + const result = await authClient.signIn.email({ + email, + password, + }) + + if (result.error) { + setError(result.error.message || "Invalid email or password") + } else { + navigate({ to: "/chat" }) + } + } catch (err) { + setError("Unable to connect. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+

+ Triage +

+

+ SRE Intelligence Platform +

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full rounded-xl border-0 bg-muted/50 px-4 py-2.5 text-sm text-foreground shadow-neu-inset outline-none placeholder:text-muted-foreground/50 focus:ring-2 focus:ring-primary/30" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full rounded-xl border-0 bg-muted/50 px-4 py-2.5 text-sm text-foreground shadow-neu-inset outline-none placeholder:text-muted-foreground/50 focus:ring-2 focus:ring-primary/30" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Don't have an account?{" "} + + Register + +

+
+
+ ) +} diff --git a/frontend/src/routes/register.tsx b/frontend/src/routes/register.tsx new file mode 100644 index 0000000..06584e2 --- /dev/null +++ b/frontend/src/routes/register.tsx @@ -0,0 +1,149 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { useState } from "react" +import { useAuth } from "@/hooks/use-auth" +import { authClient } from "@/lib/auth-client" + +export const Route = createFileRoute("/register")({ + component: RegisterPage, +}) + +function RegisterPage() { + const { isAuthenticated } = useAuth() + const navigate = useNavigate() + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Redirect authenticated users to /chat + if (isAuthenticated) { + navigate({ to: "/chat" }) + return null + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setIsSubmitting(true) + + try { + const result = await authClient.signUp.email({ + name, + email, + password, + }) + + if (result.error) { + if (result.error.message?.toLowerCase().includes("already") || result.error.code === "USER_ALREADY_EXISTS") { + setError("An account with this email already exists") + } else if (result.error.message?.toLowerCase().includes("password")) { + setError(result.error.message) + } else { + setError(result.error.message || "Registration failed. Please try again.") + } + } else { + navigate({ to: "/chat" }) + } + } catch (err) { + setError("Unable to connect. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+

+ Triage +

+

+ Create your account +

+
+ +
+
+ + setName(e.target.value)} + placeholder="Your name" + className="w-full rounded-xl border-0 bg-muted/50 px-4 py-2.5 text-sm text-foreground shadow-neu-inset outline-none placeholder:text-muted-foreground/50 focus:ring-2 focus:ring-primary/30" + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full rounded-xl border-0 bg-muted/50 px-4 py-2.5 text-sm text-foreground shadow-neu-inset outline-none placeholder:text-muted-foreground/50 focus:ring-2 focus:ring-primary/30" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full rounded-xl border-0 bg-muted/50 px-4 py-2.5 text-sm text-foreground shadow-neu-inset outline-none placeholder:text-muted-foreground/50 focus:ring-2 focus:ring-primary/30" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Already have an account?{" "} + + Log in + +

+
+
+ ) +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b9f666c..9419f16 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,4 +11,20 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + server: { + proxy: { + "/auth": { + target: "http://localhost:4111", + changeOrigin: true, + }, + "/chat": { + target: "http://localhost:4111", + changeOrigin: true, + // Only proxy POST (AI streaming) — GET /chat is the SPA route + bypass(req) { + if (req.method !== "POST") return req.url; + }, + }, + }, + }, }) diff --git a/package-lock.json b/package-lock.json index 5a15522..c4d9bfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,16 @@ }, "devDependencies": { "@libsql/client": "^0.17.2", + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^3.1.1", "drizzle-orm": "^0.45.2", + "jsdom": "^29.0.2", "typescript": "^5.8.3", "vitest": "^3.1.1", "yaml": "^2.7.1" @@ -39,6 +46,26 @@ "node": ">=18" } }, + "node_modules/@a2a-js/sdk/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ai-sdk/provider": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", @@ -198,6 +225,69 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz", + "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@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.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/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.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/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==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -234,6 +324,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -258,6 +358,159 @@ "node": ">=18" } }, + "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.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "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.1.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.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "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/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -700,6 +953,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "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/@graphql-typed-document-node/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", @@ -1005,9 +1276,9 @@ } }, "node_modules/@mastra/core": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.24.1.tgz", - "integrity": "sha512-Y0a0HZl7AMsuqrKM1OLePTJRaCr71w0RcUbCuaTgGN527jUTpfKmj9KJoNKqx9OTYUgZQ5ssNJMM6oDxsj2gxQ==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.24.0.tgz", + "integrity": "sha512-eDHkpTtuA7WwneUXHzBdCmdx2shoj+GoEGLqttX7CpmhtDAl4jofnUDsXtU7uIDF2nRrCKoYavrygQL5NBwkNQ==", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "~0.2.5", @@ -1323,6 +1594,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1861,6 +2148,104 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "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", + "peer": true, + "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/@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", + "peer": true + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2002,6 +2387,26 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2284,6 +2689,16 @@ "sprintf-js": "~1.0.2" } }, + "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/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2312,6 +2727,13 @@ "js-tokens": "^10.0.0" } }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2352,6 +2774,16 @@ ], "license": "MIT" }, + "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.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -2603,6 +3035,31 @@ } } }, + "node_modules/cross-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/cross-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2617,6 +3074,34 @@ "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": 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", @@ -2627,6 +3112,20 @@ "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2644,6 +3143,13 @@ } } }, + "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/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2718,6 +3224,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", + "peer": true + }, "node_modules/dotenv": { "version": "17.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", @@ -2899,6 +3413,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=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", @@ -3670,6 +4197,19 @@ } } }, + "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/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3731,6 +4271,16 @@ "node": ">= 4" } }, + "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", @@ -3798,6 +4348,13 @@ "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", @@ -3930,9 +4487,9 @@ } }, "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, "license": "MIT" }, @@ -3942,11 +4499,52 @@ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@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.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "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" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, "node_modules/json-schema": { @@ -4044,6 +4642,17 @@ "node": "20 || >=22" } }, + "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", + "peer": true, + "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", @@ -4290,6 +4899,13 @@ "url": "https://opencollective.com/unified" } }, + "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", @@ -4913,6 +5529,16 @@ "node": ">= 0.6" } }, + "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", @@ -5135,6 +5761,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.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", @@ -5228,6 +5867,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postal-mime": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", @@ -5263,6 +5949,47 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "peer": true, + "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-format/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==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -5298,6 +6025,16 @@ "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.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -5363,6 +6100,53 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "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", + "peer": true + }, + "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/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -5545,6 +6329,27 @@ "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==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -5923,6 +6728,19 @@ "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/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -5936,13 +6754,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5966,18 +6777,12 @@ "uuid": "^10.0.0" } }, - "node_modules/svix/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } + "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/test-exclude": { "version": "7.0.2", @@ -6055,6 +6860,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6070,12 +6895,31 @@ "integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==", "license": "MIT" }, + "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": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } }, "node_modules/trough": { "version": "2.2.0", @@ -6140,6 +6984,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6251,16 +7105,16 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/vary": { @@ -6471,6 +7325,19 @@ } } }, + "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", @@ -6482,21 +7349,38 @@ } }, "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "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" + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } }, - "node_modules/whatwg-url": { + "node_modules/whatwg-mimetype": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "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": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "@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": { @@ -6656,6 +7540,23 @@ } } }, + "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/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/package.json b/package.json index c461ace..472c3a8 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,22 @@ "scripts": { "test": "vitest run", "test:infra": "vitest run tests/", - "test:runtime": "vitest run runtime/src/" + "test:runtime": "vitest run runtime/src/", + "test:e2e": "playwright test", + "test:e2e:auth": "playwright test tests/fe-auth-pages/" }, "devDependencies": { "@libsql/client": "^0.17.2", + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^3.1.1", "drizzle-orm": "^0.45.2", + "jsdom": "^29.0.2", "typescript": "^5.8.3", "vitest": "^3.1.1", "yaml": "^2.7.1" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..24a4fe7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/fe-auth-pages', + testMatch: '**/*.e2e.ts', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, // auth tests must be sequential (shared state) + retries: 1, + reporter: [['list']], + use: { + baseURL: 'http://localhost:3001', + headless: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}); diff --git a/runtime/init-db.mjs b/runtime/init-db.mjs new file mode 100644 index 0000000..49e7298 --- /dev/null +++ b/runtime/init-db.mjs @@ -0,0 +1,90 @@ +/** + * Create auth and app tables in LibSQL if they don't exist. + * Runs before the server starts in both dev and production Docker. + */ +import { createClient } from '@libsql/client'; + +const url = process.env.LIBSQL_URL || 'http://libsql:8080'; +const client = createClient({ url }); + +const tables = [ + `CREATE TABLE IF NOT EXISTS auth_user ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + email_verified INTEGER NOT NULL DEFAULT 0, + image TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS auth_session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + ip_address TEXT, + user_agent TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS auth_account ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at INTEGER, + refresh_token_expires_at INTEGER, + scope TEXT, + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS auth_verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS wiki_documents ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + file_path TEXT NOT NULL, + summary TEXT NOT NULL, + pass INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS wiki_chunks ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES wiki_documents(id) ON DELETE CASCADE, + content TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + embedding F32_BLOB(1536), + created_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS local_tickets ( + id TEXT PRIMARY KEY, + linear_issue_id TEXT, + title TEXT NOT NULL, + description TEXT NOT NULL, + severity TEXT NOT NULL, + priority INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'triage', + assignee_id TEXT REFERENCES auth_user(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + synced_at INTEGER + )`, +]; + +for (const sql of tables) { + const name = sql.match(/CREATE TABLE IF NOT EXISTS (\w+)/)[1]; + await client.execute(sql); + console.log(`[init-db] ${name} OK`); +} +console.log('[init-db] All tables ready'); diff --git a/runtime/src/auth/index.ts b/runtime/src/auth/index.ts index 7f88fa5..495ee22 100644 --- a/runtime/src/auth/index.ts +++ b/runtime/src/auth/index.ts @@ -13,6 +13,8 @@ const trustedOrigins = (() => { origins.push(process.env.BETTER_AUTH_URL); } else { origins.push(devOrigin); + // Allow SSH port-forwarded origins in dev (e.g. localhost:3002) + origins.push('http://localhost:3002'); } return origins; })(); diff --git a/runtime/src/lib/auth.ts b/runtime/src/lib/auth.ts index e47baf9..122937f 100644 --- a/runtime/src/lib/auth.ts +++ b/runtime/src/lib/auth.ts @@ -13,6 +13,8 @@ const trustedOrigins = (() => { origins.push(process.env.BETTER_AUTH_URL); } else { origins.push(devOrigin); + // Allow SSH port-forward dev testing (localhost:3002) + origins.push('http://localhost:3002'); } return origins; })(); diff --git a/runtime/src/mastra/index.ts b/runtime/src/mastra/index.ts index 2d0e104..87fd743 100644 --- a/runtime/src/mastra/index.ts +++ b/runtime/src/mastra/index.ts @@ -2,7 +2,6 @@ import { Mastra } from '@mastra/core'; import { registerApiRoute } from '@mastra/core/server'; import { LibSQLStore } from '@mastra/libsql'; import { chatRoute } from '@mastra/ai-sdk'; -import { registerApiRoute } from '@mastra/core/server'; import { LinearClient } from '@linear/sdk'; import type { Context } from 'hono'; diff --git a/specs/active/SPEC-20260409-002-qa-report.md b/specs/active/SPEC-20260409-002-qa-report.md new file mode 100644 index 0000000..d08cc7e --- /dev/null +++ b/specs/active/SPEC-20260409-002-qa-report.md @@ -0,0 +1,88 @@ +# QA Report — SPEC-20260409-002: Auth Pages (Login/Register) + +**Date:** 2026-04-09 +**Inspector:** Lyra (QA Warden) — updated post E2E rewrite +**Recommendation:** GO ✅ + +## Test Results + +### E2E Tests (Playwright — real stack, zero mocks) + +- **Total:** 33 | **Passed:** 33 | **Failed:** 0 | **Skipped:** 0 +- **Test file:** auth-e2e.e2e.ts +- **Duration:** ~16s +- **Stack:** Frontend (Vite :3001) + Backend (Mastra :4111) + LibSQL + Better Auth +- **Browser:** Chromium headless + +| Test Group | Tests | Status | +|-----------|-------|--------| +| Preconditions (health checks) | 3 | ✅ PASS | +| Auth Guard — unauthenticated redirect | 3 | ✅ PASS | +| Login Page Rendering | 7 | ✅ PASS | +| Register Page Rendering | 5 | ✅ PASS | +| Register Flow (real sign-up) | 2 | ✅ PASS | +| Login Flow (real sign-in) | 4 | ✅ PASS | +| Authenticated User Experience (sidebar) | 7 | ✅ PASS | +| Auth Proxy Integration | 1 | ✅ PASS | +| Navigation (cross-links) | 2 | ✅ PASS | + +### Previous Tests (replaced) + +The original 53 unit tests (5 files) used `readFileSync` + `toContain` — static +string-matching with zero behavioral coverage. They were deleted and replaced +with the E2E suite above, which tests real user flows against the running stack. + +## Requirements Validation + +| Req ID | Description | Priority | Verdict | Evidence | +|--------|-------------|----------|---------|----------| +| REQ-FE01 | Better Auth Client SDK Setup | P0 | ✅ PASS | Proxy test confirms frontend→backend auth flow works | +| REQ-FE02 | useAuth Hook Implementation | P0 | ✅ PASS | Sidebar shows real user name/email from session | +| REQ-FE03 | Login Page | P0 | ✅ PASS | Full login flow: render→fill→submit→redirect→sidebar | +| REQ-FE04 | Register Page | P0 | ✅ PASS | Full register flow: render→fill→submit→auto-login→sidebar | +| REQ-FE05 | Auth Guard (Protected Routes) | P0 | ✅ PASS | /, /board, /settings all redirect to /login; /login, /register redirect to app when authenticated | +| REQ-FE06 | Vite Dev Proxy Configuration | P1 | ✅ PASS | API request through :3001/auth/* returns real user data | + +**Requirements satisfied: 6/6 (100%)** + +## Implementation Files + +| File | Lines | Status | Purpose | +|------|-------|--------|---------| +| frontend/src/lib/auth-client.ts | 15 | ✅ Created | Better Auth client SDK singleton | +| frontend/src/hooks/use-auth.ts | 37 | ✅ Rewritten | useAuth hook with real Better Auth session | +| frontend/src/routes/login.tsx | 123 | ✅ Created | Login page with neumorphic UX | +| frontend/src/routes/register.tsx | 149 | ✅ Created | Register page with neumorphic UX | +| frontend/src/routes/__root.tsx | 145 | ✅ Modified | Auth guard, sidebar user info, logout button | +| frontend/vite.config.ts | 30 | ✅ Modified | Dev proxy /auth + /chat (POST only) | +| runtime/src/auth/index.ts | 51 | ✅ Modified | Added dev trusted origin for SSH port forwarding | +| playwright.config.ts | 20 | ✅ Created | E2E test configuration | +| tests/fe-auth-pages/auth-e2e.e2e.ts | 355 | ✅ Created | 33 E2E behavioral tests | + +## Linear Issue Acceptance Criteria + +| Criterion | Verdict | Evidence | +|-----------|---------|----------| +| User can register with email/password | ✅ PASS | E2E test #19: real sign-up → auto-login → sidebar shows user | +| User can login and be redirected to chat | ✅ PASS | E2E test #21: real sign-in → redirect → sidebar visible | +| Unauthenticated access redirects to login | ✅ PASS | E2E tests #4-6: /, /board, /settings → /login | + +## Additional Fixes (post-QA) + +| Fix | Description | +|-----|-------------| +| Logout button | Added sign-out button (LogOut icon) in sidebar user section | +| /chat proxy bypass | GET /chat serves SPA, only POST /chat proxies to backend | +| docker-compose.override | `mastra dev --port` → `PORT=4111` env var (CLI flag removed) | +| Dev trusted origin | `localhost:3002` for SSH port-forwarded testing | + +## Issues Found + +- **Spec says max-w-sm, implementation uses max-w-md** — cosmetic, not blocking +- **Spec listed "Sign-out UI" as out of scope** — implemented anyway (logout button added) + +## Recommendation + +**GO** ✅ — All 6 requirements verified with 33 E2E tests against the real stack. +Zero mocks. Login, register, auth guard, logout, and proxy all functional end-to-end. +Backend (Better Auth + Drizzle + LibSQL) confirmed operational. Ready for COMPLETE stage. diff --git a/specs/active/SPEC-20260409-002.md b/specs/active/SPEC-20260409-002.md new file mode 100644 index 0000000..ac020b3 --- /dev/null +++ b/specs/active/SPEC-20260409-002.md @@ -0,0 +1,314 @@ +# SPEC-20260409-002: Auth Pages (Login/Register) + +**ID:** SPEC-20260409-002 +**Name:** fe-auth-pages-login-register +**Stage:** COMPLETE +**Created:** 2026-04-09 +**Updated:** 2026-04-09 +**Author:** Chenko +**Linear Issue:** FE-03 — Auth Pages (Login/Register) +**Epic:** Frontend +**Tier:** 1 (Must Ship) +**Estimate:** 2h + +## Purpose + +Implement login and registration pages for the Triage app, connecting the frontend to the Better Auth backend (INFRA-02). Users must be able to register, login, and be redirected to the chat interface. Unauthenticated users must be blocked from all app routes. + +## Scope + +### In Scope +- Better Auth client SDK integration in frontend +- `useAuth` hook wired to Better Auth (replacing current stub) +- Login page (`/login`) with email/password form +- Register page (`/register`) with name/email/password form +- Auth guard in root route (redirect unauthenticated → `/login`) +- Post-login/register redirect to `/chat` +- Vite dev proxy for `/auth/*` requests to runtime +- UX spec styling: neumorphic card, navy background, orange CTA + +### Out of Scope +- OAuth/social login providers +- Password reset / forgot password flow +- Email verification flow +- User profile/settings page +- Session refresh/token rotation (backend handles 7-day fixed expiry) +- ~~Sign-out UI (future ticket)~~ — implemented: logout button in sidebar + +## Dependencies + +| Dependency | Status | Details | +|-----------|--------|---------| +| INFRA-02 (Better Auth backend) | ✅ Merged to dev, present in this branch | `runtime/src/auth/index.ts`, endpoints at `/auth/*` | +| Better Auth endpoints | ✅ Available | POST `/auth/sign-up/email`, POST `/auth/sign-in/email`, GET `/auth/get-session`, POST `/auth/sign-out` | +| shadcn/ui components | ✅ Available | Card, Input, Button, Label already in `frontend/src/components/ui/` | +| Design system CSS | ✅ Available | Neumorphic shadows, Navy/Orange palette, Space Grotesk/Inter fonts in `index.css` | + +## Requirements + +### REQ-FE01: Better Auth Client SDK Setup +**Priority:** P0 +**Description:** The frontend SHALL have a Better Auth client instance configured to communicate with the backend auth endpoints via the Caddy proxy (same-origin). + +#### Acceptance Criteria +- **GIVEN** the frontend app **WHEN** it initializes **THEN** a Better Auth client is available with `baseURL` pointing to the current origin and `basePath: "/auth"` +- **GIVEN** the auth client **WHEN** any auth request is made **THEN** cookies are sent with `credentials: "include"` + +#### Scenarios +- **Happy path:** Auth client is created at module level, importable from `@/lib/auth-client` +- **Edge case:** Dev mode uses Vite proxy to forward `/auth/*` to `localhost:4111`; production uses Caddy reverse proxy +- **Error case:** If backend is unreachable, auth client methods reject with network error + +#### Implementation +- Create `frontend/src/lib/auth-client.ts` +- Install `better-auth` package in frontend +- Export `authClient` singleton created via `createAuthClient` from `better-auth/react` + +### REQ-FE02: useAuth Hook Implementation +**Priority:** P0 +**Description:** The `useAuth` hook SHALL replace the current stub with a real implementation backed by the Better Auth client SDK, providing reactive session state. + +#### Acceptance Criteria +- **GIVEN** a component using `useAuth()` **WHEN** a session exists **THEN** `isAuthenticated` is `true` and `user` contains `{id, email, name}` +- **GIVEN** a component using `useAuth()` **WHEN** no session exists **THEN** `isAuthenticated` is `false` and `user` is `null` +- **GIVEN** a component using `useAuth()` **WHEN** session check is in progress **THEN** `isLoading` is `true` + +#### Scenarios +- **Happy path:** User is logged in → hook returns user data, `isAuthenticated: true` +- **Edge case:** Session expired (7-day TTL) → `get-session` returns null → `isAuthenticated: false` +- **Error case:** Network failure during session check → `isLoading` resolves to false, `isAuthenticated: false` + +#### Implementation +- Rewrite `frontend/src/hooks/use-auth.ts` +- Use `authClient.useSession()` from Better Auth React integration +- Export additional methods: `signIn`, `signUp`, `signOut` from the auth client +- Maintain existing `AuthState` interface shape for backward compatibility + +### REQ-FE03: Login Page +**Priority:** P0 +**Description:** The app SHALL provide a login page at `/login` with email/password form following the UX design spec. + +#### Acceptance Criteria +- **GIVEN** an unauthenticated user navigates to `/login` **WHEN** the page loads **THEN** a centered card with email and password inputs is displayed on a navy background +- **GIVEN** the login form **WHEN** the user submits valid credentials **THEN** the user is redirected to `/chat` +- **GIVEN** the login form **WHEN** the user submits invalid credentials **THEN** an error message is displayed inline without page reload +- **GIVEN** the login page **WHEN** rendered **THEN** it uses the neumorphic design system (shadow-neu-raised card, shadow-neu-inset inputs, orange CTA button) + +#### Scenarios +- **Happy path:** User enters valid email/password → clicks "Log In" → redirected to `/chat` +- **Edge case:** User is already authenticated → visiting `/login` redirects to `/chat` +- **Error case:** Wrong password → inline error "Invalid email or password", form not cleared +- **Error case:** Empty fields → HTML5 validation prevents submission (required attributes) +- **Error case:** Backend unreachable → error message "Unable to connect. Please try again." + +#### UX Design Spec +- **Background:** `bg-navy` (#1F337A) full viewport +- **Card:** Centered, `shadow-neu-raised`, max-w-sm, rounded-2xl +- **Heading:** "Triage" in Space Grotesk (`font-heading`), large, white +- **Subheading:** One-liner tagline in Inter, muted white/steel-blue +- **Inputs:** Email + Password, `shadow-neu-inset` styling, labels in Inter +- **CTA Button:** "Log In", `bg-orange` (#F28B0D / #E87D08), white text, full-width +- **Footer link:** "Don't have an account? Register" → navigates to `/register` +- **Typography:** Space Grotesk for heading, Inter for everything else + +#### Implementation +- Create `frontend/src/routes/login.tsx` (TanStack Router file-based route) +- Use existing shadcn Card, Input, Button components with neumorphic class overrides +- Call `authClient.signIn.email()` on form submit +- Use `useNavigate()` for post-login redirect to `/chat` + +### REQ-FE04: Register Page +**Priority:** P0 +**Description:** The app SHALL provide a registration page at `/register` with name, email, and password form following the same UX design spec as login. + +#### Acceptance Criteria +- **GIVEN** an unauthenticated user navigates to `/register` **WHEN** the page loads **THEN** a centered card with name, email, and password inputs is displayed on a navy background +- **GIVEN** the register form **WHEN** the user submits valid data **THEN** an account is created and the user is automatically logged in and redirected to `/chat` +- **GIVEN** the register form **WHEN** the email is already registered **THEN** an error message is displayed inline +- **GIVEN** the register page **WHEN** rendered **THEN** it matches the neumorphic styling of the login page + +#### Scenarios +- **Happy path:** User fills name/email/password → clicks "Create Account" → account created → redirected to `/chat` +- **Edge case:** User is already authenticated → visiting `/register` redirects to `/chat` +- **Error case:** Duplicate email → inline error "An account with this email already exists" +- **Error case:** Weak password (if backend enforces) → inline error with backend message +- **Error case:** Backend unreachable → error message "Unable to connect. Please try again." + +#### UX Design Spec +- Same visual style as login page (navy bg, neumorphic card, orange CTA) +- **CTA Button:** "Create Account" instead of "Log In" +- **Footer link:** "Already have an account? Log in" → navigates to `/login` +- **Extra field:** "Name" input above email + +#### Implementation +- Create `frontend/src/routes/register.tsx` (TanStack Router file-based route) +- Call `authClient.signUp.email()` with `{ name, email, password }` +- Auto-login after signup by calling `signIn.email()` or relying on Better Auth's auto-session +- Use `useNavigate()` for redirect to `/chat` + +### REQ-FE05: Auth Guard (Protected Routes) +**Priority:** P0 +**Description:** The root route SHALL enforce authentication by redirecting unauthenticated users to `/login`, while allowing access to auth pages without a session. + +#### Acceptance Criteria +- **GIVEN** an unauthenticated user **WHEN** they navigate to any route except `/login` or `/register` **THEN** they are redirected to `/login` +- **GIVEN** an authenticated user **WHEN** they navigate to `/login` or `/register` **THEN** they are redirected to `/chat` +- **GIVEN** the auth state is loading **WHEN** any page renders **THEN** a loading spinner is shown (not a flash of login page) + +#### Scenarios +- **Happy path:** Authenticated user navigates freely between `/chat`, `/board`, `/settings` +- **Edge case:** Direct URL access to `/chat` while unauthenticated → redirect to `/login` +- **Edge case:** Browser refresh while authenticated → loading spinner → content (no flash) +- **Error case:** Session check fails (network) → redirect to `/login` as fallback + +#### Implementation +- Uncomment and activate the auth guard in `frontend/src/routes/__root.tsx` (lines 20-31) +- Add `Navigate` import from `@tanstack/react-router` +- Add authenticated-user redirect away from auth pages +- Update the sidebar user info section to show real user data from `useAuth()` + +### REQ-FE06: Vite Dev Proxy Configuration +**Priority:** P1 +**Description:** The Vite dev server SHALL proxy `/auth/*` requests to the Mastra runtime so auth works in local development without Caddy. + +#### Acceptance Criteria +- **GIVEN** the frontend runs via `vite dev` **WHEN** a request to `/auth/*` is made **THEN** it is proxied to `http://localhost:4111/auth/*` +- **GIVEN** the proxy **WHEN** forwarding requests **THEN** cookies and headers are preserved + +#### Scenarios +- **Happy path:** `POST /auth/sign-in/email` from Vite dev → proxied to runtime:4111 → session cookie set +- **Edge case:** Runtime is not running → proxy returns 502, frontend shows connection error +- **Error case:** CORS issues avoided because proxy makes it same-origin + +#### Implementation +- Add `server.proxy` config in `frontend/vite.config.ts` for `/auth` → `http://localhost:4111` +- Also proxy `/chat` endpoint (already needed for AI SDK chat transport) + +## Technical Approach + +### Architecture +``` +Browser → Vite dev (:5173) → proxy /auth/* → Mastra runtime (:4111) → Better Auth → LibSQL +Browser → Caddy (:3001) → /auth/* → Mastra runtime (:4111) → Better Auth → LibSQL +``` + +### Better Auth Client Setup +```typescript +// frontend/src/lib/auth-client.ts +import { createAuthClient } from "better-auth/react" + +export const authClient = createAuthClient({ + baseURL: window.location.origin, // same-origin via proxy/Caddy + basePath: "/auth", +}) +``` + +### Key Technical Decisions +1. **Same-origin auth** — No CORS needed. Vite proxy in dev, Caddy in prod. Auth client uses `window.location.origin`. +2. **HttpOnly cookies** — Frontend cannot read session token. Must call `GET /auth/get-session` to check auth state. +3. **Better Auth React integration** — `authClient.useSession()` provides reactive hook with auto-refetch. +4. **TanStack Router file-based routing** — Login/Register are file routes at `routes/login.tsx` and `routes/register.tsx`. +5. **No additional state management** — Better Auth client handles session caching internally. + +### Files to Create/Modify + +| File | Action | Owner (Subagent) | +|------|--------|-------------------| +| `frontend/src/lib/auth-client.ts` | CREATE | Agent A | +| `frontend/src/hooks/use-auth.ts` | REWRITE | Agent A | +| `frontend/vite.config.ts` | MODIFY (add proxy) | Agent A | +| `frontend/src/routes/login.tsx` | CREATE | Agent B | +| `frontend/src/routes/register.tsx` | CREATE | Agent C | +| `frontend/src/routes/__root.tsx` | MODIFY (activate guard) | Agent C | + +## Test Strategy + +### Unit Tests (vitest + @testing-library/react) +- `useAuth` hook returns correct states (loading, authenticated, unauthenticated) +- Login form validates required fields +- Register form validates required fields +- Auth guard redirects correctly based on auth state + +### Integration Tests +- Login flow: submit form → API call → redirect +- Register flow: submit form → API call → auto-login → redirect +- Protected route redirect when unauthenticated + +### Coverage Target +- All auth client functions covered +- All form submission paths covered +- All error states covered + +## Implementation Plan — Parallel Subagent Strategy + +This spec SHALL be implemented using 3 parallel Hermes subagents (model: `claude-sonnet-4-6`) to maximize speed within the 2h estimate. + +### Subagent Architecture + +``` +Orchestrator (this session) +├── Agent A: Auth Foundation [REQ-FE01, REQ-FE02, REQ-FE06] +├── Agent B: Login Page [REQ-FE03] +└── Agent C: Register + Guard [REQ-FE04, REQ-FE05] + (all run in parallel) +``` + +### Agent A — Auth Foundation +**Scope:** REQ-FE01, REQ-FE02, REQ-FE06 +**Files owned:** +- CREATE `frontend/src/lib/auth-client.ts` +- REWRITE `frontend/src/hooks/use-auth.ts` +- MODIFY `frontend/vite.config.ts` (add proxy for `/auth` and `/chat`) + +**Context to inject:** +- Backend auth config from `runtime/src/auth/index.ts` (basePath: '/auth', emailAndPassword enabled) +- Available endpoints: POST `/auth/sign-up/email`, POST `/auth/sign-in/email`, GET `/auth/get-session`, POST `/auth/sign-out` +- Current stub shape in `use-auth.ts` (must maintain `AuthState` interface) +- Better Auth React client: `createAuthClient` from `better-auth/react`, `authClient.useSession()` returns `{ data: session, isPending, error }` +- Install `better-auth` in frontend via `cd frontend && npm install better-auth` + +### Agent B — Login Page +**Scope:** REQ-FE03 +**Files owned:** +- CREATE `frontend/src/routes/login.tsx` + +**Context to inject:** +- TanStack Router file-based routing: `createFileRoute("/login")` pattern (see existing `chat.tsx`) +- Auth client interface: `import { authClient } from "@/lib/auth-client"`, call `authClient.signIn.email({ email, password })` +- Available components: `Card, CardHeader, CardContent, CardFooter` from `@/components/ui/card`, `Input` from `@/components/ui/input`, `Button` from `@/components/ui/button` +- UX spec: navy bg (#1F337A → `bg-navy`), neumorphic card (`shadow-neu-raised`), inset inputs (`shadow-neu-inset`), orange CTA (`bg-orange`), heading in `font-heading` (Space Grotesk), body in `font-sans` (Inter) +- Post-login: `useNavigate()` → `/chat` +- Link to `/register` in footer +- CSS vars already defined: `--color-navy`, `--color-orange`, `--shadow-neu-raised`, `--shadow-neu-inset` + +### Agent C — Register Page + Auth Guard +**Scope:** REQ-FE04, REQ-FE05 +**Files owned:** +- CREATE `frontend/src/routes/register.tsx` +- MODIFY `frontend/src/routes/__root.tsx` (activate auth guard) + +**Context to inject:** +- Same UX spec and component context as Agent B +- Auth client: `authClient.signUp.email({ name, email, password })` for registration +- Current `__root.tsx` content (full file) — uncomment lines 20-31, add `Navigate` import, add redirect-away from auth pages if authenticated +- Sidebar user info: replace hardcoded "K" / "Koki" / "Developer" with `user.name` from `useAuth()` +- Post-register: auto-login + redirect to `/chat` + +### Execution Order +1. **Parallel dispatch:** All 3 agents launch simultaneously via `delegate_task` with `acp_command: "claude"` and `acp_args: ["--acp", "--stdio", "--model", "claude-sonnet-4-6"]` +2. **Orchestrator waits** for all agents to complete +3. **Orchestrator verifies:** typecheck (`cd frontend && npx tsc --noEmit`), route generation (`npx tanstack-router generate`) +4. **Fix pass:** If typecheck fails, orchestrator applies targeted patches +5. **Commit:** Single commit with all changes + +### Subagent Toolsets +- All agents: `["terminal", "file"]` — no web access needed, all context injected in goal +- Working directory: `~/hackathon/triage-feature-fe-auth-pages` + +### Key Constraints for Subagents +- Do NOT modify files owned by other agents +- Use existing shadcn/ui components — do NOT install new UI libraries +- Use existing CSS variables and design tokens — do NOT create new theme files +- Better Auth React client uses `createAuthClient` from `"better-auth/react"` (NOT `"better-auth/client"`) +- Form state management: plain React `useState` — no form libraries +- All imports use `@/` alias (mapped to `frontend/src/` via vite.config.ts) diff --git a/tests/fe-auth-pages/auth-e2e.e2e.ts b/tests/fe-auth-pages/auth-e2e.e2e.ts new file mode 100644 index 0000000..0a0a341 --- /dev/null +++ b/tests/fe-auth-pages/auth-e2e.e2e.ts @@ -0,0 +1,355 @@ +/** + * SPEC-20260409-002 — Auth Pages (Login/Register) — E2E Tests + * + * Real behavioral tests against the running stack. + * Frontend: http://localhost:3001 (Vite dev) + * Backend/Auth: http://localhost:4111 (Mastra + Better Auth) + * + * Zero mocks. If these pass, the feature works. + */ +import { test, expect, type Page } from '@playwright/test'; + +// Unique email per test run to avoid collisions +const TS = Date.now(); +const TEST_USER = { + name: 'E2E Tester', + email: `e2e-${TS}@test.com`, + password: 'TestPass123!', +}; + +// Helper: clear cookies to ensure unauthenticated state +async function clearAuth(page: Page) { + await page.context().clearCookies(); +} + +// ───────────────────────────────────────────────────────────────────── +// PRECONDITION: Stack Health +// ───────────────────────────────────────────────────────────────────── + +test.describe('Preconditions', () => { + test('frontend is reachable', async ({ page }) => { + const res = await page.goto('/'); + expect(res?.status()).toBe(200); + }); + + test('backend health endpoint is reachable', async ({ request }) => { + const res = await request.get('http://localhost:4111/health'); + expect(res.status()).toBe(200); + }); + + test('auth endpoint is functional', async ({ request }) => { + // Test that Better Auth responds (even if creds are wrong) + const res = await request.post('http://localhost:4111/auth/sign-in/email', { + data: { email: 'nobody@nowhere.com', password: 'x' }, + }); + // 401 or 200 both mean auth is alive — just not 404 or 500 + expect(res.status()).toBeLessThan(500); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE05: Auth Guard — unauthenticated users redirect to /login +// ───────────────────────────────────────────────────────────────────── + +test.describe('Auth Guard (REQ-FE05)', () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + }); + + test('redirects unauthenticated user from / to /login', async ({ page }) => { + await page.goto('/'); + await page.waitForURL('**/login', { timeout: 10_000 }); + expect(page.url()).toContain('/login'); + }); + + test('redirects unauthenticated user from /board to /login', async ({ page }) => { + await page.goto('/board'); + await page.waitForURL('**/login', { timeout: 10_000 }); + expect(page.url()).toContain('/login'); + }); + + test('redirects unauthenticated user from /settings to /login', async ({ page }) => { + await page.goto('/settings'); + await page.waitForURL('**/login', { timeout: 10_000 }); + expect(page.url()).toContain('/login'); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE03: Login Page — rendering +// ───────────────────────────────────────────────────────────────────── + +test.describe('Login Page Rendering (REQ-FE03)', () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + }); + + test('shows Triage heading', async ({ page }) => { + await expect(page.getByRole('heading', { name: /triage/i })).toBeVisible(); + }); + + test('shows email input with correct type', async ({ page }) => { + const email = page.getByLabel(/email/i); + await expect(email).toBeVisible(); + await expect(email).toHaveAttribute('type', 'email'); + }); + + test('shows password input with correct type', async ({ page }) => { + const pw = page.getByLabel(/password/i); + await expect(pw).toBeVisible(); + await expect(pw).toHaveAttribute('type', 'password'); + }); + + test('shows Log In button', async ({ page }) => { + await expect(page.getByRole('button', { name: /log in/i })).toBeVisible(); + }); + + test('shows link to register page', async ({ page }) => { + const link = page.getByRole('link', { name: /register/i }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', '/register'); + }); + + test('email and password inputs are required', async ({ page }) => { + await expect(page.getByLabel(/email/i)).toHaveAttribute('required', ''); + await expect(page.getByLabel(/password/i)).toHaveAttribute('required', ''); + }); + + test('has neumorphic card styling', async ({ page }) => { + await expect(page.locator('.shadow-neu-raised')).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE04: Register Page — rendering +// ───────────────────────────────────────────────────────────────────── + +test.describe('Register Page Rendering (REQ-FE04)', () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await page.goto('/register'); + }); + + test('shows "Create your account" subtitle', async ({ page }) => { + await expect(page.getByText(/create your account/i)).toBeVisible(); + }); + + test('shows name, email, and password inputs', async ({ page }) => { + await expect(page.getByLabel(/name/i)).toBeVisible(); + await expect(page.getByLabel(/email/i)).toBeVisible(); + await expect(page.getByLabel(/password/i)).toBeVisible(); + }); + + test('shows Create Account button', async ({ page }) => { + await expect(page.getByRole('button', { name: /create account/i })).toBeVisible(); + }); + + test('shows link to login page', async ({ page }) => { + const link = page.getByRole('link', { name: /log in/i }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', '/login'); + }); + + test('all inputs are required', async ({ page }) => { + await expect(page.getByLabel(/name/i)).toHaveAttribute('required', ''); + await expect(page.getByLabel(/email/i)).toHaveAttribute('required', ''); + await expect(page.getByLabel(/password/i)).toHaveAttribute('required', ''); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE04: Register Flow — full submit cycle +// ───────────────────────────────────────────────────────────────────── + +test.describe('Register Flow (REQ-FE04)', () => { + test('registers a new user and auto-navigates to authenticated area', async ({ page }) => { + await clearAuth(page); + await page.goto('/register'); + + await page.getByLabel(/name/i).fill(TEST_USER.name); + await page.getByLabel(/email/i).fill(TEST_USER.email); + await page.getByLabel(/password/i).fill(TEST_USER.password); + await page.getByRole('button', { name: /create account/i }).click(); + + // After successful registration, app should navigate away from /register + // The auth guard + navigate sends us to /chat (client-side routing) + await expect(page).not.toHaveURL(/\/register/, { timeout: 15_000 }); + + // User name should appear in the sidebar once authenticated + await expect(page.getByText(TEST_USER.name)).toBeVisible({ timeout: 10_000 }); + }); + + test('shows error for duplicate email registration', async ({ page }) => { + await clearAuth(page); + await page.goto('/register'); + + // Try to register same email + await page.getByLabel(/name/i).fill('Duplicate User'); + await page.getByLabel(/email/i).fill(TEST_USER.email); + await page.getByLabel(/password/i).fill(TEST_USER.password); + await page.getByRole('button', { name: /create account/i }).click(); + + // Should show an error alert + const alert = page.getByRole('alert'); + await expect(alert).toBeVisible({ timeout: 10_000 }); + await expect(alert).toContainText(/already exists/i); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE03: Login Flow — full submit cycle +// ───────────────────────────────────────────────────────────────────── + +test.describe('Login Flow (REQ-FE03)', () => { + test('logs in with valid credentials and reaches authenticated area', async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + + await page.getByLabel(/email/i).fill(TEST_USER.email); + await page.getByLabel(/password/i).fill(TEST_USER.password); + await page.getByRole('button', { name: /log in/i }).click(); + + // Should navigate away from /login to authenticated area + await expect(page).not.toHaveURL(/\/login/, { timeout: 15_000 }); + + // User name visible in sidebar confirms authentication succeeded + await expect(page.getByText(TEST_USER.name)).toBeVisible({ timeout: 10_000 }); + }); + + test('shows error for invalid credentials', async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + + await page.getByLabel(/email/i).fill('wrong@nobody.com'); + await page.getByLabel(/password/i).fill('wrongpassword'); + await page.getByRole('button', { name: /log in/i }).click(); + + const alert = page.getByRole('alert'); + await expect(alert).toBeVisible({ timeout: 10_000 }); + await expect(alert).not.toBeEmpty(); + }); + + test('clears error on successful re-submit', async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + + // First: bad creds + await page.getByLabel(/email/i).fill('wrong@nobody.com'); + await page.getByLabel(/password/i).fill('wrongpassword'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }); + + // Second: correct creds + await page.getByLabel(/email/i).fill(TEST_USER.email); + await page.getByLabel(/password/i).fill(TEST_USER.password); + await page.getByRole('button', { name: /log in/i }).click(); + + // Should navigate away (successful login) + await expect(page).not.toHaveURL(/\/login/, { timeout: 15_000 }); + }); + + test('disables button during submission', async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + + await page.getByLabel(/email/i).fill(TEST_USER.email); + await page.getByLabel(/password/i).fill(TEST_USER.password); + + const btn = page.getByRole('button', { name: /log in/i }); + await btn.click(); + + // Button should be disabled at some point during submit + // (may resolve very quickly, so we just verify the flow completes) + await expect(page).not.toHaveURL(/\/login/, { timeout: 15_000 }); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE05: Authenticated user — sidebar & redirects from auth pages +// ───────────────────────────────────────────────────────────────────── + +test.describe('Authenticated User Experience (REQ-FE05)', () => { + // Login before each test + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + await page.getByLabel(/email/i).fill(TEST_USER.email); + await page.getByLabel(/password/i).fill(TEST_USER.password); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page).not.toHaveURL(/\/login/, { timeout: 15_000 }); + // Wait for sidebar to render (indicates full auth) + await expect(page.getByText(TEST_USER.name)).toBeVisible({ timeout: 10_000 }); + }); + + test('shows sidebar with navigation links', async ({ page }) => { + await expect(page.getByRole('link', { name: /chat/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /board/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /settings/i })).toBeVisible(); + }); + + test('shows user name in sidebar', async ({ page }) => { + await expect(page.getByText(TEST_USER.name)).toBeVisible(); + }); + + test('shows user email in sidebar', async ({ page }) => { + await expect(page.getByText(TEST_USER.email)).toBeVisible(); + }); + + test('shows Triage logo in sidebar', async ({ page }) => { + // Use exact match + .first() to avoid matching "Welcome to Triage" heading + await expect(page.locator('aside').getByText('Triage', { exact: true })).toBeVisible(); + }); + + test('redirects from /login to authenticated area when already logged in', async ({ page }) => { + // Navigate to /login — auth guard should redirect us away + await page.goto('/login', { waitUntil: 'networkidle' }); + // Either we get redirected immediately, or the page loads and then redirects + // Wait for the URL to change OR for the sidebar to be visible + await expect(page.locator('aside').getByText('Triage', { exact: true })).toBeVisible({ timeout: 15_000 }); + }); + + test('redirects from /register to authenticated area when already logged in', async ({ page }) => { + await page.goto('/register', { waitUntil: 'networkidle' }); + await expect(page.locator('aside').getByText('Triage', { exact: true })).toBeVisible({ timeout: 15_000 }); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// REQ-FE06: Auth Proxy — auth requests work through the frontend +// ───────────────────────────────────────────────────────────────────── + +test.describe('Auth Proxy Integration (REQ-FE06)', () => { + test('frontend proxies auth requests to backend', async ({ request }) => { + const res = await request.post('http://localhost:3001/auth/sign-in/email', { + data: { email: TEST_USER.email, password: TEST_USER.password }, + }); + // Proxy works if we get any auth response (not 404 or 500) + expect(res.status()).toBeLessThan(500); + // Response should be JSON (not HTML error page) + const text = await res.text(); + expect(text).toContain(TEST_USER.email); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Navigation between auth pages +// ───────────────────────────────────────────────────────────────────── + +test.describe('Navigation', () => { + test('login page link navigates to register', async ({ page }) => { + await clearAuth(page); + await page.goto('/login'); + await page.getByRole('link', { name: /register/i }).click(); + await page.waitForURL('**/register'); + expect(page.url()).toContain('/register'); + }); + + test('register page link navigates to login', async ({ page }) => { + await clearAuth(page); + await page.goto('/register'); + await page.getByRole('link', { name: /log in/i }).click(); + await page.waitForURL('**/login'); + expect(page.url()).toContain('/login'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1e7f485..e381bf7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['tests/**/*.test.ts', 'runtime/src/**/*.test.ts'], + // Exclude e2e tests — those run via Playwright, not Vitest + exclude: ['tests/**/*.e2e.ts'], // Test-only mock values — no real API calls are made. // These satisfy config validation in runtime unit tests. env: {