diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3165bff --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# ============================================================================== +# REQUIRED - Generate with: openssl rand -base64 36 | tr -d '\n' +# ============================================================================== +PG_PASS= +AUTHENTIK_SECRET_KEY= + +# ============================================================================== +# OPTIONAL - Authentik Database +# ============================================================================== +PG_USER=authentik +PG_DB=authentik + +# ============================================================================== +# OPTIONAL - Ports +# ============================================================================== +AUTHENTIK_PORT_HTTP=9000 +AUTHENTIK_PORT_HTTPS=9443 +KABOOT_BACKEND_PORT=3001 + +# ============================================================================== +# OPTIONAL - Authentik Settings +# ============================================================================== +AUTHENTIK_ERROR_REPORTING=false + +# ============================================================================== +# OPTIONAL - OIDC (Override if using custom domain) +# ============================================================================== +OIDC_ISSUER=http://localhost:9000/application/o/kaboot/ +OIDC_JWKS_URI=http://localhost:9000/application/o/kaboot/jwks/ + +# ============================================================================== +# OPTIONAL - CORS (Frontend origin for backend API) +# ============================================================================== +CORS_ORIGIN=http://localhost:5173 + +# ============================================================================== +# OPTIONAL - Logging +# ============================================================================== +LOG_REQUESTS=false diff --git a/.gitignore b/.gitignore index a547bf3..5a58438 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,19 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environment secrets +.env +.env.local +.env.*.local +.env.test + +# Authentik volumes (keep structure, ignore data) +authentik/media/* +!authentik/media/.gitkeep +authentik/certs/* +!authentik/certs/.gitkeep + +# Backend data +server/data/ +*.db diff --git a/App.tsx b/App.tsx index cbfe1bf..47ed8e4 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; +import { useQuizLibrary } from './hooks/useQuizLibrary'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -7,6 +9,7 @@ import { Scoreboard } from './components/Scoreboard'; import { Podium } from './components/Podium'; import { QuizCreator } from './components/QuizCreator'; import { RevealScreen } from './components/RevealScreen'; +import { SaveQuizPrompt } from './components/SaveQuizPrompt'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -34,6 +37,8 @@ const FloatingShapes = React.memo(() => { }); function App() { + const auth = useAuth(); + const { saveQuiz } = useQuizLibrary(); const { role, gameState, @@ -46,19 +51,31 @@ function App() { startQuizGen, startManualCreation, finalizeManualQuiz, + loadSavedQuiz, joinGame, startGame, handleAnswer, hasAnswered, lastPointsEarned, nextQuestion, + showScoreboard, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, - currentPlayerId + currentPlayerId, + pendingQuizToSave, + dismissSavePrompt } = useGame(); + const handleSaveQuiz = async () => { + if (!pendingQuizToSave) return; + const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual'; + const topic = pendingQuizToSave.topic || undefined; + await saveQuiz(pendingQuizToSave.quiz, source, topic); + dismissSavePrompt(); + }; + const currentQ = quiz?.questions[currentQuestionIndex]; // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape) @@ -76,6 +93,7 @@ function App() { + <> + + {auth.isAuthenticated && pendingQuizToSave && ( + + )} + ) : null} {(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? ( @@ -131,6 +159,7 @@ function App() { correctOption={correctOpt} selectedOption={selectedOption} role={role} + onNext={showScoreboard} /> ) : null} diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..58cf634 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,20 @@ +# Kaboot Production Caddyfile +# Copy this file to Caddyfile and update the domain names + +kaboot.example.com { + root * /srv/frontend + file_server + try_files {path} /index.html + + handle /api/* { + reverse_proxy kaboot-backend:3001 + } + + handle /health { + reverse_proxy kaboot-backend:3001 + } +} + +auth.example.com { + reverse_proxy authentik-server:9000 +} diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index db75bb2..e56d575 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -31,47 +31,47 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ## Phase 1: Infrastructure Setup ### 1.1 Docker Compose Configuration -- [ ] Create `docker-compose.yml` with all services: - - [ ] PostgreSQL (Authentik database) - - [ ] Redis (Authentik cache) - - [ ] Authentik server - - [ ] Authentik worker - - [ ] Kaboot backend service -- [ ] Create `.env.example` with required variables: - - [ ] `PG_PASS` - PostgreSQL password - - [ ] `AUTHENTIK_SECRET_KEY` - Authentik secret - - [ ] `PG_USER`, `PG_DB` - Optional customization -- [ ] Create setup script to generate secrets (`scripts/setup.sh`) -- [ ] Add `authentik/` directory structure for volumes: - - [ ] `authentik/media/` - - [ ] `authentik/certs/` - - [ ] `authentik/custom-templates/` -- [ ] Update `.gitignore` for new files: - - [ ] `.env` - - [ ] `authentik/media/*` - - [ ] `authentik/certs/*` - - [ ] `server/data/` +- [x] Create `docker-compose.yml` with all services: + - [x] PostgreSQL (Authentik database) + - [x] Redis (Authentik cache) + - [x] Authentik server + - [x] Authentik worker + - [x] Kaboot backend service +- [x] Create `.env.example` with required variables: + - [x] `PG_PASS` - PostgreSQL password + - [x] `AUTHENTIK_SECRET_KEY` - Authentik secret + - [x] `PG_USER`, `PG_DB` - Optional customization +- [x] Create setup script to generate secrets (`scripts/setup.sh`) +- [x] Add `authentik/` directory structure for volumes: + - [x] `authentik/media/` + - [x] `authentik/certs/` + - [x] `authentik/custom-templates/` +- [x] Update `.gitignore` for new files: + - [x] `.env` + - [x] `authentik/media/*` + - [x] `authentik/certs/*` + - [x] `server/data/` ### 1.2 Authentik Configuration Documentation -- [ ] Document initial setup steps in `docs/AUTHENTIK_SETUP.md`: - - [ ] Navigate to `http://localhost:9000/if/flow/initial-setup/` - - [ ] Create admin account - - [ ] Create OAuth2/OIDC Application + Provider: - - [ ] Application name: `Kaboot` - - [ ] Application slug: `kaboot` - - [ ] Provider type: `OAuth2/OIDC` - - [ ] Client type: `Public` (SPA with PKCE) - - [ ] Client ID: `kaboot-spa` - - [ ] Redirect URIs: `http://localhost:5173/callback`, `http://localhost:5173/silent-renew.html` - - [ ] Scopes: `openid`, `profile`, `email`, `offline_access` - - [ ] Note down OIDC endpoints (issuer, JWKS URI, etc.) +- [x] Document initial setup steps in `docs/AUTHENTIK_SETUP.md`: + - [x] Navigate to `http://localhost:9000/if/flow/initial-setup/` + - [x] Create admin account + - [x] Create OAuth2/OIDC Application + Provider: + - [x] Application name: `Kaboot` + - [x] Application slug: `kaboot` + - [x] Provider type: `OAuth2/OIDC` + - [x] Client type: `Public` (SPA with PKCE) + - [x] Client ID: `kaboot-spa` + - [x] Redirect URIs: `http://localhost:5173/callback`, `http://localhost:5173/silent-renew.html` + - [x] Scopes: `openid`, `profile`, `email`, `offline_access` + - [x] Note down OIDC endpoints (issuer, JWKS URI, etc.) --- ## Phase 2: Backend API Development ### 2.1 Project Setup -- [ ] Create `server/` directory structure: +- [x] Create `server/` directory structure: ``` server/ ├── Dockerfile @@ -84,233 +84,236 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ├── routes/ └── services/ ``` -- [ ] Initialize `package.json` with dependencies: - - [ ] `express` - Web framework - - [ ] `better-sqlite3` - SQLite driver - - [ ] `jsonwebtoken` - JWT verification - - [ ] `jwks-rsa` - JWKS client for Authentik - - [ ] `uuid` - ID generation - - [ ] `cors` - CORS middleware - - [ ] Dev deps: `typescript`, `@types/*`, `tsx` -- [ ] Create `tsconfig.json` for Node.js -- [ ] Create `Dockerfile` for backend container +- [x] Initialize `package.json` with dependencies: + - [x] `express` - Web framework + - [x] `better-sqlite3` - SQLite driver + - [x] `jsonwebtoken` - JWT verification + - [x] `jwks-rsa` - JWKS client for Authentik + - [x] `uuid` - ID generation + - [x] `cors` - CORS middleware + - [x] Dev deps: `typescript`, `@types/*`, `tsx` +- [x] Create `tsconfig.json` for Node.js +- [x] Create `Dockerfile` for backend container ### 2.2 Database Layer -- [ ] Create `server/src/db/schema.sql`: - - [ ] `users` table (synced from OIDC claims) - - [ ] `quizzes` table (with `user_id` foreign key) - - [ ] `questions` table (with `quiz_id` foreign key) - - [ ] `answer_options` table (with `question_id` foreign key) - - [ ] Indexes for foreign keys -- [ ] Create `server/src/db/connection.ts`: - - [ ] Initialize better-sqlite3 connection - - [ ] Run schema on startup - - [ ] Export db instance +- [x] Create `server/src/db/schema.sql`: + - [x] `users` table (synced from OIDC claims) + - [x] `quizzes` table (with `user_id` foreign key) + - [x] `questions` table (with `quiz_id` foreign key) + - [x] `answer_options` table (with `question_id` foreign key) + - [x] Indexes for foreign keys +- [x] Create `server/src/db/connection.ts`: + - [x] Initialize better-sqlite3 connection + - [x] Run schema on startup + - [x] Export db instance ### 2.3 Authentication Middleware -- [ ] Create `server/src/middleware/auth.ts`: - - [ ] JWKS client setup pointing to Authentik - - [ ] `requireAuth` middleware function: - - [ ] Extract Bearer token from Authorization header - - [ ] Verify JWT signature against JWKS - - [ ] Validate issuer matches Authentik - - [ ] Attach decoded user to request - - [ ] Define `AuthenticatedRequest` interface +- [x] Create `server/src/middleware/auth.ts`: + - [x] JWKS client setup pointing to Authentik + - [x] `requireAuth` middleware function: + - [x] Extract Bearer token from Authorization header + - [x] Verify JWT signature against JWKS + - [x] Validate issuer matches Authentik + - [x] Attach decoded user to request + - [x] Define `AuthenticatedRequest` interface ### 2.4 API Routes -- [ ] Create `server/src/routes/quizzes.ts`: - - [ ] `GET /api/quizzes` - List user's quizzes (with question count) - - [ ] `GET /api/quizzes/:id` - Get full quiz with questions and options - - [ ] `POST /api/quizzes` - Save new quiz (upsert user from token) - - [ ] `PUT /api/quizzes/:id` - Update existing quiz - - [ ] `DELETE /api/quizzes/:id` - Delete quiz (verify ownership) -- [ ] Create `server/src/routes/users.ts`: - - [ ] `GET /api/users/me` - Get current user profile -- [ ] Create `server/src/index.ts`: - - [ ] Express app setup - - [ ] CORS configuration (allow frontend origin) - - [ ] JSON body parser - - [ ] Mount routes - - [ ] Error handling middleware - - [ ] Start server on port 3001 +- [x] Create `server/src/routes/quizzes.ts`: + - [x] `GET /api/quizzes` - List user's quizzes (with question count) + - [x] `GET /api/quizzes/:id` - Get full quiz with questions and options + - [x] `POST /api/quizzes` - Save new quiz (upsert user from token) + - [x] `PUT /api/quizzes/:id` - Update existing quiz + - [x] `DELETE /api/quizzes/:id` - Delete quiz (verify ownership) +- [x] Create `server/src/routes/users.ts`: + - [x] `GET /api/users/me` - Get current user profile +- [x] Create `server/src/index.ts`: + - [x] Express app setup + - [x] CORS configuration (allow frontend origin) + - [x] JSON body parser + - [x] Mount routes + - [x] Error handling middleware + - [x] Start server on port 3001 ### 2.5 Backend Testing -- [ ] Test API manually with curl/Postman: - - [ ] Verify 401 without token - - [ ] Verify endpoints work with valid Authentik token - - [ ] Verify quiz CRUD operations - - [ ] Verify user sync from token claims +- [x] Test API with automated test suite: + - [x] Verify 401 without token + - [x] Verify endpoints work with valid Authentik token + - [x] Verify quiz CRUD operations + - [x] Verify user sync from token claims --- ## Phase 3: Frontend Authentication ### 3.1 Dependencies -- [ ] Add to `package.json`: - - [ ] `react-oidc-context` - React OIDC hooks - - [ ] `oidc-client-ts` - Underlying OIDC client +- [x] Add to `package.json`: + - [x] `react-oidc-context` - React OIDC hooks + - [x] `oidc-client-ts` - Underlying OIDC client - [ ] Run `npm install` ### 3.2 OIDC Configuration -- [ ] Create `src/config/oidc.ts`: - - [ ] Define `oidcConfig` object: - - [ ] `authority` - Authentik issuer URL - - [ ] `client_id` - `kaboot-spa` - - [ ] `redirect_uri` - `${origin}/callback` - - [ ] `post_logout_redirect_uri` - `${origin}` - - [ ] `response_type` - `code` (PKCE) - - [ ] `scope` - `openid profile email offline_access` - - [ ] `automaticSilentRenew` - `true` - - [ ] `userStore` - `WebStorageStateStore` with localStorage - - [ ] `onSigninCallback` - Clean URL after redirect +- [x] Create `src/config/oidc.ts`: + - [x] Define `oidcConfig` object: + - [x] `authority` - Authentik issuer URL + - [x] `client_id` - `kaboot-spa` + - [x] `redirect_uri` - `${origin}/callback` + - [x] `post_logout_redirect_uri` - `${origin}` + - [x] `response_type` - `code` (PKCE) + - [x] `scope` - `openid profile email offline_access` + - [x] `automaticSilentRenew` - `true` + - [x] `userStore` - `WebStorageStateStore` with localStorage + - [x] `onSigninCallback` - Clean URL after redirect ### 3.3 Auth Provider Setup -- [ ] Modify `src/main.tsx`: - - [ ] Import `AuthProvider` from `react-oidc-context` - - [ ] Import `oidcConfig` - - [ ] Wrap `` with `` +- [x] Modify `index.tsx`: + - [x] Import `AuthProvider` from `react-oidc-context` + - [x] Import `oidcConfig` + - [x] Wrap `` with `` ### 3.4 Auth UI Components -- [ ] Create `src/components/AuthButton.tsx`: - - [ ] Use `useAuth()` hook - - [ ] Show loading state while auth initializing - - [ ] Show "Sign In" button when unauthenticated - - [ ] Show username + "Sign Out" button when authenticated -- [ ] Modify `src/components/Landing.tsx`: - - [ ] Add `` to top-right corner - - [ ] Style consistently with existing design +- [x] Create `components/AuthButton.tsx`: + - [x] Use `useAuth()` hook + - [x] Show loading state while auth initializing + - [x] Show "Sign In" button when unauthenticated + - [x] Show username + "Sign Out" button when authenticated +- [x] Modify `components/Landing.tsx`: + - [x] Add `` to top-right corner + - [x] Style consistently with existing design ### 3.5 Authenticated Fetch Hook -- [ ] Create `src/hooks/useAuthenticatedFetch.ts`: - - [ ] Use `useAuth()` to get access token - - [ ] Create `authFetch` wrapper that: - - [ ] Adds `Authorization: Bearer ` header - - [ ] Adds `Content-Type: application/json` - - [ ] Handles 401 by triggering silent renew - - [ ] Export `{ authFetch, isAuthenticated }` +- [x] Create `hooks/useAuthenticatedFetch.ts`: + - [x] Use `useAuth()` to get access token + - [x] Create `authFetch` wrapper that: + - [x] Adds `Authorization: Bearer ` header + - [x] Adds `Content-Type: application/json` + - [x] Handles 401 by triggering silent renew + - [x] Export `{ authFetch, isAuthenticated }` --- ## Phase 4: Quiz Library Feature ### 4.1 Quiz Library Hook -- [ ] Create `src/hooks/useQuizLibrary.ts`: - - [ ] State: `quizzes`, `loading`, `error` - - [ ] `fetchQuizzes()` - GET /api/quizzes - - [ ] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz - - [ ] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes - - [ ] `deleteQuiz(id)` - DELETE /api/quizzes/:id - - [ ] Handle loading and error states +- [x] Create `hooks/useQuizLibrary.ts`: + - [x] State: `quizzes`, `loading`, `error` + - [x] `fetchQuizzes()` - GET /api/quizzes + - [x] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz + - [x] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes + - [x] `deleteQuiz(id)` - DELETE /api/quizzes/:id + - [x] Handle loading and error states ### 4.2 Quiz Library UI -- [ ] Create `src/components/QuizLibrary.tsx`: - - [ ] Modal overlay design (consistent with app style) - - [ ] Header: "My Quizzes" with close button - - [ ] Search/filter input (optional, future enhancement) - - [ ] Quiz list: - - [ ] Show title, question count, source badge (AI/Manual), date - - [ ] Click to select - - [ ] Delete button with confirmation - - [ ] Footer: "Load Selected" and "Cancel" buttons - - [ ] Empty state: "No saved quizzes yet" - - [ ] Loading state: Skeleton/spinner +- [x] Create `components/QuizLibrary.tsx`: + - [x] Modal overlay design (consistent with app style) + - [x] Header: "My Library" with close button + - [x] Quiz list: + - [x] Show title, question count, source badge (AI/Manual), date + - [x] Click to load quiz + - [x] Delete button with confirmation + - [x] Empty state: "No saved quizzes yet" + - [x] Loading state: Spinner ### 4.3 Landing Page Integration -- [ ] Modify `src/components/Landing.tsx`: - - [ ] Add "My Quizzes" button (only visible when authenticated) - - [ ] Add state for quiz library modal visibility - - [ ] Render `` modal when open - - [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz +- [x] Modify `components/Landing.tsx`: + - [x] Add "My Quizzes" button (only visible when authenticated) + - [x] Add state for quiz library modal visibility + - [x] Render `` modal when open + - [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz ### 4.4 Types Update -- [ ] Modify `src/types.ts`: - - [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) - - [ ] Add `QuizListItem` interface (for list view) - - [ ] Add `QuizSource` type: `'manual' | 'ai_generated'` +- [x] Modify `types.ts`: + - [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) + - [x] Add `QuizListItem` interface (for list view) + - [x] Add `QuizSource` type: `'manual' | 'ai_generated'` + +### 4.5 Game Hook Integration +- [x] Modify `hooks/useGame.ts`: + - [x] Add `loadSavedQuiz(quiz)` function + - [x] Export for App.tsx to consume --- ## Phase 5: Save Integration ### 5.1 Save After AI Generation -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Add `pendingQuizToSave` state - - [ ] After successful AI generation, set `pendingQuizToSave` - - [ ] Add `savePendingQuiz()` and `dismissSavePrompt()` functions - - [ ] Export these for UI to consume -- [ ] Create `src/components/SaveQuizPrompt.tsx`: - - [ ] Modal asking "Save this quiz to your library?" - - [ ] Show quiz title - - [ ] "Save" and "Skip" buttons - - [ ] Only show when authenticated +- [x] Modify `hooks/useGame.ts`: + - [x] Add `pendingQuizToSave` state + - [x] After successful AI generation, set `pendingQuizToSave` + - [x] Add `dismissSavePrompt()` function + - [x] Export these for UI to consume +- [x] Create `components/SaveQuizPrompt.tsx`: + - [x] Modal asking "Save this quiz to your library?" + - [x] Show quiz title + - [x] "Save" and "Skip" buttons + - [x] Loading state while saving +- [x] Wire up in `App.tsx`: + - [x] Show SaveQuizPrompt in LOBBY state when authenticated and pendingQuizToSave exists + - [x] Handle save via useQuizLibrary hook ### 5.2 Save in Quiz Creator -- [ ] Modify `src/components/QuizCreator.tsx`: - - [ ] Add checkbox or toggle: "Save to my library" - - [ ] Pass `shouldSave` flag to `onFinalize` -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Update `finalizeManualQuiz` to accept save preference - - [ ] If save requested + authenticated, call `saveQuiz` +- [x] Modify `components/QuizCreator.tsx`: + - [x] Add "Save to my library" checkbox (only shown when authenticated) + - [x] Pass `saveToLibrary` flag to `onFinalize` +- [x] Modify `hooks/useGame.ts`: + - [x] Update `finalizeManualQuiz` to accept save preference + - [x] If save requested, set `pendingQuizToSave` -### 5.3 Load Quiz Flow -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Add `loadSavedQuiz(quiz: Quiz)` function - - [ ] Initialize game state with loaded quiz - - [ ] Transition to LOBBY state -- [ ] Wire up from Landing → QuizLibrary → useGame +### 5.3 Load Quiz Flow (Already done in Phase 4) +- [x] `loadSavedQuiz(quiz: Quiz)` function in useGame.ts +- [x] Wire up from Landing → QuizLibrary → useGame --- ## Phase 6: Polish & Error Handling ### 6.1 Loading States -- [ ] Add loading indicators: - - [ ] Quiz library list loading - - [ ] Quiz loading when selected - - [ ] Save operation in progress -- [ ] Disable buttons during async operations +- [x] Add loading indicators: + - [x] Quiz library list loading + - [x] Quiz loading when selected + - [x] Save operation in progress +- [x] Disable buttons during async operations ### 6.2 Error Handling -- [ ] Display user-friendly error messages: - - [ ] Failed to load quiz library - - [ ] Failed to save quiz - - [ ] Failed to delete quiz - - [ ] Network/auth errors -- [ ] Add retry mechanisms where appropriate +- [x] Display user-friendly error messages: + - [x] Failed to load quiz library + - [x] Failed to save quiz + - [x] Failed to delete quiz + - [x] Network/auth errors +- [x] Add retry mechanisms where appropriate ### 6.3 Toast Notifications (Optional) -- [ ] Add `react-hot-toast` or similar -- [ ] Show success toasts: - - [ ] "Quiz saved successfully" - - [ ] "Quiz deleted" -- [ ] Show error toasts for failures +- [x] Add `react-hot-toast` or similar +- [x] Show success toasts: + - [x] "Quiz saved successfully" + - [x] "Quiz deleted" +- [x] Show error toasts for failures ### 6.4 Edge Cases -- [ ] Handle auth token expiry gracefully -- [ ] Handle offline state -- [ ] Handle concurrent save attempts -- [ ] Validate quiz data before save +- [x] Handle auth token expiry gracefully +- [x] Handle offline state +- [x] Handle concurrent save attempts +- [x] Validate quiz data before save --- ## Phase 7: Documentation & Deployment ### 7.1 Documentation -- [ ] Update main `README.md`: - - [ ] Add Docker Compose setup instructions - - [ ] Document environment variables - - [ ] Add Authentik configuration steps -- [ ] Create `docs/AUTHENTIK_SETUP.md` (detailed IdP setup) -- [ ] Create `docs/API.md` (backend API documentation) +- [x] Update main `README.md`: + - [x] Add Docker Compose setup instructions + - [x] Document environment variables + - [x] Add Authentik configuration steps +- [x] Create `docs/AUTHENTIK_SETUP.md` (detailed IdP setup) +- [x] Create `docs/API.md` (backend API documentation) ### 7.2 Production Considerations -- [ ] Document production deployment: - - [ ] HTTPS setup (reverse proxy) - - [ ] Update redirect URIs for production domain - - [ ] Database backup strategy - - [ ] Authentik email configuration -- [ ] Add health check endpoints -- [ ] Add logging configuration +- [x] Document production deployment: + - [x] HTTPS setup (reverse proxy) + - [x] Update redirect URIs for production domain + - [x] Database backup strategy + - [x] Authentik email configuration +- [x] Add health check endpoints +- [x] Add logging configuration --- @@ -396,14 +399,14 @@ kaboot/ ## Progress Tracking -**Last Updated**: 2026-01-13 +**Last Updated**: 2026-01-14 | Phase | Status | Notes | |-------|--------|-------| -| Phase 1 | Not Started | | -| Phase 2 | Not Started | | -| Phase 3 | Not Started | | -| Phase 4 | Not Started | | -| Phase 5 | Not Started | | -| Phase 6 | Not Started | | -| Phase 7 | Not Started | | +| Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs | +| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | +| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | +| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration | +| Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration | +| Phase 6 | **COMPLETE** | Toast notifications, loading states, error handling, edge cases | +| Phase 7 | **COMPLETE** | README, API docs, PRODUCTION docs, health checks, logging | diff --git a/README.md b/README.md index c806cde..80fa546 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,123 @@ GHBanner -# Run and deploy your AI Studio app +# Kaboot -This contains everything you need to run your app locally. +Kaboot is an AI-powered quiz party game inspired by Kahoot. It leverages the Google Gemini API to instantly generate engaging quizzes on any topic, allowing users to host and join multiplayer games with ease. -View your app in AI Studio: https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8 +## Features -## Run Locally +- **AI Quiz Generation**: Create full quizzes in seconds by simply providing a topic or prompt using Google Gemini. +- **Real-time Multiplayer**: Host games and have players join via game pins using Peer-to-Peer technology. +- **Single-player Arcade Mode**: Play against AI bots to sharpen your skills. +- **Secure Authentication**: Integrated with Authentik for robust OIDC-based user management. +- **Quiz Library**: Save, manage, and reuse your AI-generated quizzes. +- **Dynamic UI**: A premium, responsive interface built with React, Framer Motion, and Lucide. -**Prerequisites:** Node.js +## Architecture +Kaboot is built with a modern decoupled architecture: -1. Install dependencies: - `npm install` -2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: - `npm run dev` +- **Frontend**: React (Vite) with PeerJS for real-time communication. +- **Backend**: Node.js Express server managing the quiz database and session state. +- **Database**: SQLite (Better-SQLite3) for lightweight, reliable data storage. +- **Identity Provider**: Authentik (running in Docker) providing OIDC authentication. +- **Infrastructure**: Redis and PostgreSQL (supporting Authentik). +- **AI Engine**: Google Gemini API for intelligent content generation. + +## Prerequisites + +Before you begin, ensure you have the following installed: + +- **Docker & Docker Compose**: For running the backend services and authentication. +- **Node.js (v18+)**: For local frontend development. +- **Google Gemini API Key**: Required for AI quiz generation. + +## Quick Start + +The fastest way to get Kaboot running is using Docker Compose. + +### 1. Initialize Environment +Run the setup script to generate necessary secrets and create your `.env` file: + +```bash +chmod +x scripts/setup.sh +./scripts/setup.sh +``` + +### 2. Configure Gemini API +Open the newly created `.env` file and add your Gemini API key: + +```env +GEMINI_API_KEY=your_api_key_here +``` + +### 3. Start Services +Launch the entire stack using Docker Compose: + +```bash +docker compose up -d +``` + +This will start: +- **Authentik** (Port 9000) +- **PostgreSQL** & **Redis** (Internal) +- **Kaboot Backend** (Port 3001) + +### 4. Setup Authentication +Follow the [Authentik Setup Guide](docs/AUTHENTIK_SETUP.md) to configure the OIDC provider. + +## Development Setup + +If you want to run the frontend in development mode with hot-reloading: + +1. **Install Dependencies**: + ```bash + npm install + ``` + +2. **Run Development Server**: + ```bash + npm run dev + ``` + The frontend will be available at `http://localhost:5173`. + +## Configuration + +Kaboot uses environment variables for configuration. Refer to `.env.example` for a complete list. + +| Variable | Description | Default | +|----------|-------------|---------| +| `KABOOT_BACKEND_PORT` | Port for the Express backend | `3001` | +| `AUTHENTIK_PORT_HTTP` | Port for Authentik web interface | `9000` | +| `GEMINI_API_KEY` | Your Google Gemini API key | (Required) | +| `CORS_ORIGIN` | Allowed origin for API requests | `http://localhost:5173` | +| `PG_PASS` | PostgreSQL password for Authentik | (Generated) | +| `AUTHENTIK_SECRET_KEY` | Secret key for Authentik | (Generated) | + +## Testing + +### Backend Tests +To run the backend test suite: + +```bash +cd server +npm install +npm test +``` + +## Documentation + +- [Authentik Configuration](docs/AUTHENTIK_SETUP.md) +- [API Reference](docs/API.md) + +## Troubleshooting + +- **Authentik Initial Setup**: If you can't access the setup page, ensure `authentik-server` container is healthy using `docker compose ps`. +- **CORS Errors**: Verify that `CORS_ORIGIN` in your `.env` matches your frontend URL. +- **Gemini API Issues**: Ensure your API key is valid and has sufficient quota. Check the browser console for specific error messages from the GenAI SDK. +- **Database Locked**: If you encounter SQLite locking issues, ensure only one instance of the backend is writing to the database volume. + +--- + +View your app in AI Studio: [https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8](https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8) diff --git a/ai-todo.md b/ai-todo.md new file mode 100644 index 0000000..1149712 --- /dev/null +++ b/ai-todo.md @@ -0,0 +1,3 @@ +# Kaboot Documentation Tasks + +- [x] Create /Users/joey/Downloads/kaboot/docs/PRODUCTION.md with production deployment guide diff --git a/authentik/certs/.gitkeep b/authentik/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/authentik/custom-templates/.gitkeep b/authentik/custom-templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/authentik/media/.gitkeep b/authentik/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx new file mode 100644 index 0000000..f76a3b9 --- /dev/null +++ b/components/AuthButton.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useAuth } from 'react-oidc-context'; +import { LogIn, LogOut, User, Loader2 } from 'lucide-react'; + +export const AuthButton: React.FC = () => { + const auth = useAuth(); + + if (auth.isLoading) { + return ( +
+ +
+ ); + } + + if (auth.error) { + return ( +
+ Auth Error +
+ ); + } + + if (auth.isAuthenticated) { + return ( +
+
+ + + {auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'} + +
+ +
+ ); + } + + return ( + + ); +}; diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index 111e7d8..c6866fc 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -27,9 +27,9 @@ export const GameScreen: React.FC = ({ }) => { const isClient = role === 'CLIENT'; const displayOptions = question?.options || []; + const timeLeftSeconds = Math.ceil(timeLeft / 1000); - // Timer styling logic - const isUrgent = timeLeft < 5 && timeLeft > 0; + const isUrgent = timeLeftSeconds <= 5 && timeLeftSeconds > 0; const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white'; const timerTextColor = isUrgent ? 'text-red-500' : 'text-theme-primary'; const timerAnimation = isUrgent ? 'animate-ping' : ''; @@ -45,9 +45,9 @@ export const GameScreen: React.FC = ({ {/* Whimsical Timer */}
-
- {timeLeft} -
+
+ {timeLeftSeconds} +
diff --git a/components/Landing.tsx b/components/Landing.tsx index 5b81058..8e69678 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,20 +1,46 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react'; +import { useAuth } from 'react-oidc-context'; +import { AuthButton } from './AuthButton'; +import { QuizLibrary } from './QuizLibrary'; +import { useQuizLibrary } from '../hooks/useQuizLibrary'; +import type { Quiz } from '../types'; interface LandingProps { onGenerate: (topic: string) => void; onCreateManual: () => void; + onLoadQuiz: (quiz: Quiz) => void; onJoin: (pin: string, name: string) => void; isLoading: boolean; error: string | null; } -export const Landing: React.FC = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => { +export const Landing: React.FC = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { + const auth = useAuth(); const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); const [topic, setTopic] = useState(''); const [pin, setPin] = useState(''); const [name, setName] = useState(''); + const [libraryOpen, setLibraryOpen] = useState(false); + + const { + quizzes, + loading: libraryLoading, + loadingQuizId, + deletingQuizId, + error: libraryError, + fetchQuizzes, + loadQuiz, + deleteQuiz, + retry: retryLibrary + } = useQuizLibrary(); + + useEffect(() => { + if (libraryOpen && auth.isAuthenticated) { + fetchQuizzes(); + } + }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -26,8 +52,24 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on if (pin.trim() && name.trim()) onJoin(pin, name); }; + const handleLoadQuiz = async (id: string) => { + try { + const quiz = await loadQuiz(id); + setLibraryOpen(false); + onLoadQuiz(quiz); + } catch (err) { + if (err instanceof Error && err.message.includes('redirecting')) { + return; + } + console.error('Failed to load quiz:', err); + } + }; + return ( -
+
+
+ +
= ({ onGenerate, onCreateManual, on > Create Manually + + {auth.isAuthenticated && ( + + )}
) : (
@@ -122,6 +173,19 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on )} + + setLibraryOpen(false)} + quizzes={quizzes} + loading={libraryLoading} + loadingQuizId={loadingQuizId} + deletingQuizId={deletingQuizId} + error={libraryError} + onLoadQuiz={handleLoadQuiz} + onDeleteQuiz={deleteQuiz} + onRetry={retryLibrary} + />
); }; diff --git a/components/QuizCreator.tsx b/components/QuizCreator.tsx index 726e527..7260cd1 100644 --- a/components/QuizCreator.tsx +++ b/components/QuizCreator.tsx @@ -1,21 +1,24 @@ import React, { useState } from 'react'; +import { useAuth } from 'react-oidc-context'; import { Quiz, Question, AnswerOption } from '../types'; import { v4 as uuidv4 } from 'uuid'; -import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react'; +import { Plus, Save, Trash2, CheckCircle, Circle, X, BookOpen } from 'lucide-react'; import { COLORS, SHAPES } from '../constants'; interface QuizCreatorProps { - onFinalize: (quiz: Quiz) => void; + onFinalize: (quiz: Quiz, saveToLibrary: boolean) => void; onCancel: () => void; } export const QuizCreator: React.FC = ({ onFinalize, onCancel }) => { + const auth = useAuth(); const [title, setTitle] = useState(''); const [questions, setQuestions] = useState([]); const [qText, setQText] = useState(''); const [options, setOptions] = useState(['', '', '', '']); const [reasons, setReasons] = useState(['', '', '', '']); const [correctIdx, setCorrectIdx] = useState(0); + const [saveToLibrary, setSaveToLibrary] = useState(false); const handleAddQuestion = () => { if (!qText.trim() || options.some(o => !o.trim())) { @@ -53,7 +56,7 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel } const handleFinalize = () => { if (!title.trim() || questions.length === 0) return; - onFinalize({ title, questions }); + onFinalize({ title, questions }, saveToLibrary); }; return ( @@ -176,7 +179,25 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel }
-
+
+ {auth.isAuthenticated ? ( + + ) : ( +
+ )} +
+ +
+ {loading && ( +
+ +

Loading your quizzes...

+
+ )} + + {!loading && error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && quizzes.length === 0 && ( +
+
+ +
+

No saved quizzes yet

+

Create or generate a quiz to save it here!

+
+ )} + + {!loading && !error && quizzes.map((quiz) => ( + !isAnyOperationInProgress && onLoadQuiz(quiz.id)} + > +
+
+
+ {quiz.source === 'ai_generated' ? ( + + AI + + ) : ( + + Manual + + )} + + {formatDate(quiz.createdAt)} + +
+ +

+ {quiz.title} +

+ +

+ {quiz.questionCount} question{quiz.questionCount !== 1 ? 's' : ''} + {quiz.aiTopic && • Topic: {quiz.aiTopic}} +

+
+ +
+ {loadingQuizId === quiz.id ? ( +
+ +
+ ) : deletingQuizId === quiz.id ? ( +
+ +
+ ) : confirmDeleteId === quiz.id ? ( +
e.stopPropagation()}> + + +
+ ) : ( + <> + +
+ +
+ + )} +
+
+
+ ))} +
+ + + + )} + + ); +}; diff --git a/components/RevealScreen.tsx b/components/RevealScreen.tsx index a501c68..f9ab10f 100644 --- a/components/RevealScreen.tsx +++ b/components/RevealScreen.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { motion, useSpring, useTransform } from 'framer-motion'; -import { Check, X, Flame, Trophy } from 'lucide-react'; +import { Check, X, Flame, ChevronRight } from 'lucide-react'; import { AnswerOption, Player, GameRole } from '../types'; import { SHAPES, COLORS } from '../constants'; import confetti from 'canvas-confetti'; @@ -34,6 +34,7 @@ interface RevealScreenProps { correctOption: AnswerOption; selectedOption?: AnswerOption | null; role: GameRole; + onNext?: () => void; } export const RevealScreen: React.FC = ({ @@ -43,7 +44,8 @@ export const RevealScreen: React.FC = ({ streak, correctOption, selectedOption, - role + role, + onNext }) => { const isHost = role === 'HOST'; @@ -59,19 +61,18 @@ export const RevealScreen: React.FC = ({ } }, [isCorrect, isHost]); - // -- HOST VIEW -- if (isHost) { const ShapeIcon = SHAPES[correctOption.shape]; const colorClass = COLORS[correctOption.color]; return (
-
+
The correct answer is @@ -80,7 +81,7 @@ export const RevealScreen: React.FC = ({ initial={{ scale: 0, rotate: -10 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", bounce: 0.5 }} - className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20`} + className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20 relative z-10`} >
@@ -99,6 +100,19 @@ export const RevealScreen: React.FC = ({ )} + + {onNext && ( + + Continue to Scoreboard + + + )}
); } diff --git a/components/SaveQuizPrompt.tsx b/components/SaveQuizPrompt.tsx new file mode 100644 index 0000000..5cc8b86 --- /dev/null +++ b/components/SaveQuizPrompt.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Save, X, Loader2, BrainCircuit } from 'lucide-react'; + +interface SaveQuizPromptProps { + isOpen: boolean; + quizTitle: string; + onSave: () => Promise; + onSkip: () => void; +} + +export const SaveQuizPrompt: React.FC = ({ + isOpen, + quizTitle, + onSave, + onSkip +}) => { + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + setIsSaving(true); + try { + await onSave(); + } finally { + setIsSaving(false); + } + }; + + return ( + + {isOpen && ( + <> + + e.stopPropagation()} + className="bg-white w-full max-w-md flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden" + > +
+
+ +
+ +

Save this Quiz?

+

+ "{quizTitle}" generated successfully! Would you like to save it to your library? +

+ +
+ + + +
+
+ +
+ +
+
+
+ + )} +
+ ); +}; diff --git a/constants.ts b/constants.ts index ea4f964..6f3e583 100644 --- a/constants.ts +++ b/constants.ts @@ -22,6 +22,7 @@ export const BOT_NAMES = [ ]; export const QUESTION_TIME = 20; // seconds +export const QUESTION_TIME_MS = 20000; // milliseconds export const POINTS_PER_QUESTION = 1000; export const PLAYER_COLORS = [ diff --git a/data/kaboot.db-shm b/data/kaboot.db-shm new file mode 100644 index 0000000..c385666 Binary files /dev/null and b/data/kaboot.db-shm differ diff --git a/data/kaboot.db-wal b/data/kaboot.db-wal new file mode 100644 index 0000000..788e385 Binary files /dev/null and b/data/kaboot.db-wal differ diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml new file mode 100644 index 0000000..32093f8 --- /dev/null +++ b/docker-compose.caddy.yml @@ -0,0 +1,35 @@ +# Caddy Reverse Proxy for Kaboot Production +# +# This compose file adds Caddy as a reverse proxy with automatic HTTPS. +# Use with the main docker-compose.yml using the -f flag. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +# +# Prerequisites: +# 1. Create a Caddyfile in the project root (see docs/PRODUCTION.md) +# 2. Build the frontend: npm run build +# 3. Update your domain DNS to point to your server + +services: + caddy: + image: caddy:2-alpine + container_name: kaboot-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./dist:/srv/frontend:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - kaboot-backend + - authentik-server + networks: + - kaboot-network + +volumes: + caddy-data: + caddy-config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c001fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,128 @@ +services: + # ═══════════════════════════════════════════════════════════════════════════ + # AUTHENTIK - Identity Provider + # ═══════════════════════════════════════════════════════════════════════════ + + postgresql: + image: docker.io/library/postgres:16-alpine + container_name: kaboot-postgresql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 5s + volumes: + - postgresql-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + POSTGRES_DB: ${PG_DB:-authentik} + networks: + - kaboot-network + + redis: + image: docker.io/library/redis:alpine + container_name: kaboot-redis + command: --save 60 1 --loglevel warning + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 3s + volumes: + - redis-data:/data + networks: + - kaboot-network + + authentik-server: + image: ghcr.io/goauthentik/server:2025.2 + container_name: kaboot-authentik-server + restart: unless-stopped + command: server + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?authentik secret key required} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING:-false} + volumes: + - ./authentik/media:/media + - ./authentik/custom-templates:/templates + ports: + - "${AUTHENTIK_PORT_HTTP:-9000}:9000" + - "${AUTHENTIK_PORT_HTTPS:-9443}:9443" + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - kaboot-network + + authentik-worker: + image: ghcr.io/goauthentik/server:2025.2 + container_name: kaboot-authentik-worker + restart: unless-stopped + command: worker + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + user: root + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./authentik/media:/media + - ./authentik/certs:/certs + - ./authentik/custom-templates:/templates + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - kaboot-network + + # ═══════════════════════════════════════════════════════════════════════════ + # KABOOT - Application Backend + # ═══════════════════════════════════════════════════════════════════════════ + + kaboot-backend: + build: + context: ./server + dockerfile: Dockerfile + container_name: kaboot-backend + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_PATH: /data/kaboot.db + OIDC_ISSUER: http://localhost:9000/application/o/kaboot/ + OIDC_JWKS_URI: http://localhost:9000/application/o/kaboot/jwks/ + OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} + volumes: + - kaboot-data:/data + ports: + - "${KABOOT_BACKEND_PORT:-3001}:3001" + depends_on: + - authentik-server + networks: + - kaboot-network + +volumes: + postgresql-data: + redis-data: + kaboot-data: + +networks: + kaboot-network: + driver: bridge diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..7bb8ee6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,239 @@ +# Kaboot Backend API Documentation + +The Kaboot backend provides a RESTful API for managing quizzes and user profiles. + +## Base URL +The backend server runs at: +`http://localhost:3001` + +## Authentication +All routes under `/api/*` require authentication using an OIDC Bearer token in the `Authorization` header. + +```http +Authorization: Bearer +``` + +Tokens are issued by the Authentik Identity Provider. + +--- + +## Endpoints + +### Health Check + +#### GET /health +Check the operational status of the backend server. + +- **Authentication**: None +- **Response**: `200 OK` +- **Example Response**: + ```json + { + "status": "ok", + "timestamp": "2026-01-13T10:00:00.000Z" + } + ``` +- **Curl Example**: + ```bash + curl http://localhost:3001/health + ``` + +--- + +### User Profile + +#### GET /api/users/me +Retrieve the profile of the currently authenticated user. + +- **Authentication**: Required +- **Response**: `200 OK` +- **Example Response**: + ```json + { + "id": "user_123", + "username": "jdoe", + "email": "jdoe@example.com", + "displayName": "John Doe", + "createdAt": "2026-01-13T10:00:00.000Z", + "lastLogin": "2026-01-13T10:00:00.000Z", + "isNew": false + } + ``` +- **Curl Example**: + ```bash + curl -H "Authorization: Bearer " http://localhost:3001/api/users/me + ``` + +--- + +### Quizzes + +#### GET /api/quizzes +List all quizzes created by the authenticated user. + +- **Authentication**: Required +- **Response**: `200 OK` +- **Example Response**: + ```json + [ + { + "id": "quiz_8b3f...", + "title": "World Capitals", + "source": "manual", + "aiTopic": null, + "createdAt": "2026-01-13T10:00:00.000Z", + "updatedAt": "2026-01-13T10:00:00.000Z", + "questionCount": 5 + } + ] + ``` +- **Curl Example**: + ```bash + curl -H "Authorization: Bearer " http://localhost:3001/api/quizzes + ``` + +#### GET /api/quizzes/:id +Retrieve full details for a specific quiz, including all questions and answer options. + +- **Authentication**: Required +- **Response**: `200 OK` or `404 Not Found` +- **Example Response**: + ```json + { + "id": "quiz_8b3f...", + "title": "World Capitals", + "source": "manual", + "aiTopic": null, + "createdAt": "2026-01-13T10:00:00.000Z", + "updatedAt": "2026-01-13T10:00:00.000Z", + "questions": [ + { + "id": "question_456", + "text": "What is the capital of Japan?", + "timeLimit": 20, + "orderIndex": 0, + "options": [ + { + "id": "option_789", + "text": "Tokyo", + "isCorrect": true, + "shape": "triangle", + "color": "red", + "reason": "Tokyo is the political and economic center of Japan.", + "orderIndex": 0 + } + ] + } + ] + } + ``` +- **Curl Example**: + ```bash + curl -H "Authorization: Bearer " http://localhost:3001/api/quizzes/quiz_8b3f... + ``` + +#### POST /api/quizzes +Create a new quiz. + +- **Authentication**: Required +- **Validation Rules**: + - `title`: Required, non-empty string. + - `source`: Required, must be `'manual'` or `'ai_generated'`. + - `questions`: Required, array with at least 1 question. + - **Question validation**: + - `text`: Required, non-empty string. + - `options`: Required, array with at least 2 options. + - Each option: + - `text`: Required, non-empty string. + - `isCorrect`: Required, boolean. + - `shape`: Required, one of `'triangle'`, `'diamond'`, `'circle'`, `'square'`. + - `color`: Required, one of `'red'`, `'blue'`, `'yellow'`, `'green'`. + - `reason`: Optional, string explaining the answer. + - At least one option must be marked as correct (`isCorrect: true`). +- **Request Body**: + ```json + { + "title": "Space Exploration", + "source": "manual", + "questions": [ + { + "text": "Which planet is known as the Red Planet?", + "timeLimit": 20, + "options": [ + { "text": "Mars", "isCorrect": true, "shape": "triangle", "color": "red" }, + { "text": "Venus", "isCorrect": false, "shape": "diamond", "color": "blue" } + ] + } + ] + } + ``` +- **Response**: `201 Created` +- **Example Response**: + ```json + { + "id": "new_quiz_uuid" + } + ``` +- **Curl Example**: + ```bash + curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"title":"Space Quiz","source":"manual","questions":[{"text":"Which planet is known as the Red Planet?","options":[{"text":"Mars","isCorrect":true,"shape":"triangle","color":"red"},{"text":"Venus","isCorrect":false,"shape":"diamond","color":"blue"}]}]}' \ + http://localhost:3001/api/quizzes + ``` + +#### PUT /api/quizzes/:id +Update an existing quiz. This operation replaces the existing questions and options. + +- **Authentication**: Required +- **Validation Rules**: Same as `POST /api/quizzes`. +- **Response**: `200 OK` or `404 Not Found` +- **Curl Example**: + ```bash + curl -X PUT -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"title":"Updated Space Quiz","questions":[{"text":"Which planet is red?","options":[{"text":"Mars","isCorrect":true,"shape":"triangle","color":"red"},{"text":"Venus","isCorrect":false,"shape":"diamond","color":"blue"}]}]}' \ + http://localhost:3001/api/quizzes/quiz_uuid + ``` + +#### DELETE /api/quizzes/:id +Permanently delete a quiz. + +- **Authentication**: Required +- **Response**: `204 No Content` or `404 Not Found` +- **Curl Example**: + ```bash + curl -X DELETE -H "Authorization: Bearer " http://localhost:3001/api/quizzes/quiz_uuid + ``` + +--- + +## Error Responses + +The API returns standard HTTP status codes along with a JSON error object. + +- **400 Bad Request** + Returned when the request body is invalid or validation fails. + ```json + { "error": "Title is required and cannot be empty" } + ``` + +- **401 Unauthorized** + Returned when the Bearer token is missing, expired, or invalid. + ```json + { "error": "Missing or invalid authorization header" } + ``` + or + ```json + { "error": "Invalid token", "details": "..." } + ``` + +- **404 Not Found** + Returned when the requested quiz does not exist or does not belong to the user. + ```json + { "error": "Quiz not found" } + ``` + +- **500 Internal Server Error** + Returned when an unexpected error occurs on the server. + ```json + { "error": "Internal server error" } + ``` diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md new file mode 100644 index 0000000..b18ea0c --- /dev/null +++ b/docs/AUTHENTIK_SETUP.md @@ -0,0 +1,373 @@ +# Authentik Setup Guide for Kaboot + +This guide walks through configuring Authentik as the OAuth2/OIDC identity provider for Kaboot. + +## Prerequisites + +- Docker and Docker Compose installed +- Kaboot stack running (`docker compose up -d`) +- Access to `http://localhost:9000` + +## Step 1: Initial Authentik Setup + +1. Navigate to `http://localhost:9000/if/flow/initial-setup/` + - **Important**: Include the trailing slash `/` + +2. Create the admin account: + - Email: Your email address + - Password: Choose a strong password + +3. Log in with the credentials you just created + +## Step 2: Create the Kaboot Application + +1. In the Authentik admin interface, go to **Applications** > **Applications** + +2. Click **Create with provider** + +3. **Application Settings**: + | Field | Value | + |-------|-------| + | Name | `Kaboot` | + | Slug | `kaboot` | + | Launch URL | `http://localhost:5173` | + +4. Click **Next** + +## Step 3: Configure OAuth2/OIDC Provider + +1. Select **OAuth2/OIDC** as the Provider Type + +2. Click **Next** + +3. **Provider Configuration**: + | Field | Value | + |-------|-------| + | Name | `Kaboot OAuth2` | + | Authorization flow | `default-provider-authorization-implicit-consent` | + | Client type | `Public` | + | Client ID | `kaboot-spa` | + +4. **Redirect URIs** (one per line): + ``` + http://localhost:5173/callback + http://localhost:5173/silent-renew.html + http://localhost:5173 + ``` + +5. **Advanced Settings**: + | Field | Value | + |-------|-------| + | Subject mode | `Based on the User's hashed ID` | + | Include claims in id_token | `Yes` | + | Issuer mode | `Each provider has a different issuer` | + +6. **Scopes** - Ensure these are selected: + - `openid` + - `profile` + - `email` + - `offline_access` (for refresh tokens) + +7. Click **Submit** + +## Step 4: Enable User Registration (Sign Up) + +By default, Authentik only shows a login form. To allow users to sign up, you need to create an enrollment flow and link it. + +### Step 4.1: Create the Enrollment Prompt Stage + +1. Go to **Flows and Stages** > **Stages** + +2. Click **Create** + +3. Select **Prompt Stage** and click **Next** + +4. Configure: + | Field | Value | + |-------|-------| + | Name | `enrollment-prompt` | + +5. In the **Fields** section, move these to the **Selected** side: + - `default-source-enrollment-field-username` (username) + - `default-user-settings-field-email` (email) + - `default-password-change-field-password` (password) + - `default-password-change-field-password-repeat` (password_repeat) + +6. (Optional) In **Validation policies**, select `password-complexity` if you created it in Step 4.2 + +7. Click **Finish** + +### Step 4.2: (Optional) Create Password Complexity Policy + +1. Go to **Customisation** > **Policies** + +2. Click **Create** and select **Password Policy** + +3. Configure: + | Field | Value | + |-------|-------| + | Name | `password-complexity` | + | Password field | `password` | + | Minimum length | `8` | + | Amount of uppercase characters | `1` | + | Amount of lowercase characters | `1` | + | Amount of digits | `1` | + +4. Click **Finish** + +You'll add this to the enrollment prompt stage later. + +### Step 4.3: Create a Group for Kaboot Users + +1. Go to **Directory** > **Groups** + +2. Click **Create** + +3. Configure: + | Field | Value | + |-------|-------| + | Name | `kaboot-users` | + +4. Click **Create** + +### Step 4.4: Create the User Write Stage + +1. Go to **Flows and Stages** > **Stages** + +2. Click **Create** + +3. Select **User Write Stage** and click **Next** + +4. Configure: + | Field | Value | + |-------|-------| + | Name | `enrollment-user-write` | + | User creation mode | `Create users when required` | + | Create users as inactive | Unchecked | + | Group | `kaboot-users` | + +5. Click **Finish** + +### Step 4.5: Create the User Login Stage + +1. Go to **Flows and Stages** > **Stages** + +2. Click **Create** + +3. Select **User Login Stage** and click **Next** + +4. Configure: + | Field | Value | + |-------|-------| + | Name | `enrollment-user-login` | + | Session duration | `hours=24` | + | Stay signed in offset | `days=30` | + | Network binding | `No binding` | + | GeoIP binding | `No binding` | + +5. Click **Finish** + +### Step 4.6: Create the Enrollment Flow + +1. Go to **Flows and Stages** > **Flows** + +2. Click **Create** + +3. Configure: + | Field | Value | + |-------|-------| + | Name | `Enrollment Flow` | + | Title | `Sign Up` | + | Slug | `enrollment-flow` | + | Designation | `Enrollment` | + | Authentication | `No requirement` | + +4. Click **Create** + +5. Click on the newly created `enrollment-flow` + +6. Go to the **Stage Bindings** tab + +7. Click **Bind existing stage** and add stages in this order: + | Stage | Order | + |-------|-------| + | `enrollment-prompt` | 10 | + | `enrollment-user-write` | 20 | + | `enrollment-user-login` | 30 | + +### Step 4.7: Bind the Group to the Kaboot Application + +1. Go to **Applications** > **Applications** > **Kaboot** + +2. Go to the **Policy / Group / User Bindings** tab + +3. Click **Bind existing group** + +4. Select `kaboot-users` + +5. Click **Bind** + +Now users in the `kaboot-users` group (which includes all users who sign up) will have access to Kaboot. + +### Step 4.8: Link Enrollment Flow to Login + +1. Go to **Flows and Stages** > **Stages** + +2. Find and click on `default-authentication-identification` + +3. Scroll down to **Flow settings** + +4. In the **Enrollment flow** dropdown, select `enrollment-flow` + +5. Click **Update** + +Now when users visit the login page, they'll see a "Need an account? Sign up." link. + +### Optional: Add Password Recovery + +1. In **Flows and Stages** > **Stages** > `default-authentication-identification` + +2. Set **Recovery flow** to `default-recovery-flow` (if it exists) + +3. Click **Update** + +## Step 5: Verify OIDC Endpoints + +After creation, go to **Applications** > **Providers** > **Kaboot OAuth2** + +Note these endpoints (you'll need them for frontend configuration): + +| Endpoint | URL | +|----------|-----| +| Issuer | `http://localhost:9000/application/o/kaboot/` | +| Authorization | `http://localhost:9000/application/o/authorize/` | +| Token | `http://localhost:9000/application/o/token/` | +| UserInfo | `http://localhost:9000/application/o/userinfo/` | +| JWKS | `http://localhost:9000/application/o/kaboot/jwks/` | + +## Step 5: Test the Configuration + +1. Open the OpenID Configuration URL in your browser: + ``` + http://localhost:9000/application/o/kaboot/.well-known/openid-configuration + ``` + +2. You should see a JSON response with all OIDC endpoints + +## Step 6: Create a Test User + +Create a regular user for manual browser testing. + +1. Go to **Directory** > **Users** + +2. Click **Create** + +3. Fill in user details: + | Field | Value | + |-------|-------| + | Username | `kaboottest` | + | Name | `Kaboot Test` | + | Email | `kaboottest@test.com` | + +4. After creation, click on the user and go to the **Credentials** tab + +5. Click **Set password** and set it to `kaboottest` + +6. **Bind the user to the Kaboot application**: + - Go to **Applications** > **Applications** > **Kaboot** + - Click the **Policy / Group / User Bindings** tab + - Click **Bind existing user** + - Select `kaboottest` and click **Bind** + +## Step 7: Create a Service Account for API Testing + +Create a service account that can obtain tokens programmatically for automated tests. + +1. Go to **Directory** > **Users** + +2. Click **Create Service Account** + +3. Fill in details: + | Field | Value | + |-------|-------| + | Username | `kaboot-test-service` | + | Create group | Unchecked | + +4. Click **Create** + +5. **Create an App Password** for the service account: + - Click on the newly created `kaboot-test-service` user + - Go to the **App passwords** tab + - Click **Create App Password** + - Name it `api-tests` + - Copy the generated password (you won't see it again!) + +6. **Bind the service account to the Kaboot application**: + - Go to **Applications** > **Applications** > **Kaboot** + - Click the **Policy / Group / User Bindings** tab + - Click **Bind existing user** + - Select `kaboot-test-service` and click **Bind** + +7. **Save credentials to `server/.env.test`**: + ```bash + TEST_USERNAME=kaboot-test-service + TEST_PASSWORD= + ``` + +8. **Verify token generation works**: + ```bash + cd server + npm run test:get-token + ``` + + You should see "Token obtained successfully" and the access token printed. + +## Environment Variables + +Ensure your `.env` file has the correct OIDC configuration: + +```bash +OIDC_ISSUER=http://localhost:9000/application/o/kaboot/ +OIDC_JWKS_URI=http://localhost:9000/application/o/kaboot/jwks/ +``` + +For the frontend OIDC config (`src/config/oidc.ts`): + +```typescript +export const oidcConfig = { + authority: 'http://localhost:9000/application/o/kaboot/', + client_id: 'kaboot-spa', + redirect_uri: `${window.location.origin}/callback`, + // ... rest of config +}; +``` + +## Troubleshooting + +### "Invalid redirect URI" error +- Ensure all redirect URIs are added exactly as configured in the provider +- Check for trailing slashes - they must match exactly + +### "Client not found" error +- Verify the Client ID matches `kaboot-spa` +- Ensure the application is enabled (not archived) + +### CORS errors +- Authentik handles CORS automatically for configured redirect URIs +- Ensure your frontend origin (`http://localhost:5173`) is in the redirect URIs + +### Token validation fails on backend +- Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct +- The backend must be able to reach Authentik at `http://authentik-server:9000` (Docker network) + +## Production Notes + +For production deployment: + +1. Use HTTPS everywhere +2. Update all URLs from `localhost` to your domain +3. Update redirect URIs in Authentik +4. Update frontend OIDC config with production URLs +5. Update `.env` with production OIDC endpoints +6. Consider enabling Authentik error reporting +7. Configure email settings in Authentik for password recovery diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md new file mode 100644 index 0000000..31d51ad --- /dev/null +++ b/docs/PRODUCTION.md @@ -0,0 +1,326 @@ +# Production Deployment Guide + +This guide provides instructions for deploying Kaboot to a production environment. It covers security, persistence, and configuration for a robust setup. + +## Prerequisites + +- A Linux server with Docker and Docker Compose installed. +- A registered domain name (e.g., `kaboot.example.com`). +- SSL certificates (e.g., from Let's Encrypt). +- A Google Gemini API key. + +## Architecture Overview + +In production, the stack consists of: +- **Nginx**: Reverse proxy handling HTTPS and routing. +- **Authentik**: Identity Provider for authentication. +- **Kaboot Backend**: Express server for quiz logic and SQLite storage. +- **Kaboot Frontend**: Static assets served via Nginx or a dedicated service. +- **PostgreSQL**: Database for Authentik. +- **Redis**: Cache and task queue for Authentik. + +## Environment Variables + +Create a production `.env` file. Do not commit this file to version control. + +### Backend & Authentik Configuration + +```env +# Database Passwords (Generate strong secrets) +PG_PASS=your_strong_postgres_password +AUTHENTIK_SECRET_KEY=your_strong_authentik_secret + +# Infrastructure +AUTHENTIK_PORT_HTTP=9000 +KABOOT_BACKEND_PORT=3001 + +# AI Configuration +GEMINI_API_KEY=your_gemini_api_key + +# OIDC Production Settings +OIDC_ISSUER=https://auth.example.com/application/o/kaboot/ +OIDC_JWKS_URI=https://auth.example.com/application/o/kaboot/jwks/ + +# Security +CORS_ORIGIN=https://kaboot.example.com +LOG_REQUESTS=true +``` + +### Frontend Configuration + +The frontend requires environment variables at build time: +- `VITE_API_URL`: `https://kaboot.example.com/api` +- `VITE_OIDC_AUTHORITY`: `https://auth.example.com/application/o/kaboot/` + +## Docker Compose Production Example + +Create a `docker-compose.prod.yml` for your production environment: + +```yaml +services: + postgresql: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 5s + retries: 5 + volumes: + - postgresql-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: ${PG_PASS} + POSTGRES_USER: ${PG_USER:-authentik} + POSTGRES_DB: ${PG_DB:-authentik} + networks: + - kaboot-network + + redis: + image: docker.io/library/redis:alpine + restart: unless-stopped + command: --save 60 1 --loglevel warning + volumes: + - redis-data:/data + networks: + - kaboot-network + + authentik-server: + image: ghcr.io/goauthentik/server:2025.2 + restart: unless-stopped + command: server + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + volumes: + - ./authentik/media:/media + - ./authentik/custom-templates:/templates + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - kaboot-network + + authentik-worker: + image: ghcr.io/goauthentik/server:2025.2 + restart: unless-stopped + command: worker + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + user: root + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./authentik/media:/media + - ./authentik/certs:/certs + - ./authentik/custom-templates:/templates + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - kaboot-network + + kaboot-backend: + build: + context: ./server + dockerfile: Dockerfile + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_PATH: /data/kaboot.db + OIDC_ISSUER: ${OIDC_ISSUER} + OIDC_JWKS_URI: ${OIDC_JWKS_URI} + CORS_ORIGIN: ${CORS_ORIGIN} + LOG_REQUESTS: ${LOG_REQUESTS} + volumes: + - kaboot-data:/data + networks: + - kaboot-network + +volumes: + postgresql-data: + redis-data: + kaboot-data: + +networks: + kaboot-network: + driver: bridge +``` + +## HTTPS and Reverse Proxy + +Choose one of the following reverse proxy options. Caddy is recommended for its simplicity and automatic HTTPS. + +### Option 1: Caddy (Recommended) + +Caddy automatically obtains and renews SSL certificates from Let's Encrypt. + +A separate `docker-compose.caddy.yml` is provided to add Caddy to your stack. + +**Step 1: Create the Caddyfile** + +Copy and customize the example Caddyfile: + +```bash +cp Caddyfile.example Caddyfile +``` + +Edit `Caddyfile` and replace `kaboot.example.com` and `auth.example.com` with your actual domains: + +```caddyfile +kaboot.example.com { + root * /srv/frontend + file_server + try_files {path} /index.html + + handle /api/* { + reverse_proxy kaboot-backend:3001 + } + + handle /health { + reverse_proxy kaboot-backend:3001 + } +} + +auth.example.com { + reverse_proxy authentik-server:9000 +} +``` + +**Step 2: Build the Frontend** + +```bash +npm run build +``` + +This creates the `dist/` directory with production assets. + +**Step 3: Start with Caddy** + +Use both compose files together: + +```bash +docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +``` + +This will: +- Start all Kaboot services (backend, Authentik, PostgreSQL, Redis) +- Start Caddy as a reverse proxy on ports 80 and 443 +- Automatically obtain SSL certificates from Let's Encrypt + +**Step 4: Verify** + +Check that all services are running: + +```bash +docker compose -f docker-compose.yml -f docker-compose.caddy.yml ps +``` + +View Caddy logs: + +```bash +docker logs kaboot-caddy +``` + +**Stopping the Stack** + +```bash +docker compose -f docker-compose.yml -f docker-compose.caddy.yml down +``` + +### Option 2: Nginx + +Use Nginx as a reverse proxy with manual SSL certificate management. + +```nginx +server { + listen 443 ssl; + server_name kaboot.example.com; + + ssl_certificate /etc/letsencrypt/live/kaboot.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/kaboot.example.com/privkey.pem; + + location / { + root /var/www/kaboot/frontend; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://localhost:3001/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /health { + proxy_pass http://localhost:3001/health; + } +} + +server { + listen 443 ssl; + server_name auth.example.com; + + ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem; + + location / { + proxy_pass http://localhost:9000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +## Authentik Configuration for Production + +1. **Update Redirect URIs**: In the Authentik Admin interface, go to **Applications** > **Providers** > **Kaboot OAuth2**. Update the **Redirect URIs** to use your production domain: + - `https://kaboot.example.com/callback` + - `https://kaboot.example.com/silent-renew.html` + - `https://kaboot.example.com` + +2. **Email Configuration**: To enable password recovery, configure SMTP settings in Authentik. In the Admin interface, go to **System** > **Settings** and update the Email section. + - Host: your SMTP server + - Port: 587 or 465 + - Username/Password: your credentials + - Use TLS/SSL: Enabled + +## Database Backup Strategy + +Kaboot uses SQLite, making backups straightforward. + +### SQLite (Kaboot Data) +The database file is located in the `kaboot-data` volume at `/data/kaboot.db`. To back it up: +```bash +docker exec kaboot-backend sqlite3 /data/kaboot.db ".backup '/data/backup_$(date +%F).db'" +``` +Then, copy the backup file from the volume to a secure location. + +### PostgreSQL (Authentik Data) +For Authentik's metadata: +```bash +docker exec kaboot-postgresql pg_dump -U authentik authentik > authentik_backup_$(date +%F).sql +``` + +## Security Checklist + +- [ ] Change all default passwords (`PG_PASS`, `AUTHENTIK_SECRET_KEY`). +- [ ] Ensure `NODE_ENV` is set to `production`. +- [ ] Use HTTPS for all connections. +- [ ] Set `CORS_ORIGIN` to your specific frontend domain. +- [ ] Regularly back up the `kaboot.db` and PostgreSQL data. +- [ ] Monitor logs by setting `LOG_REQUESTS=true`. +- [ ] Keep Docker images updated to the latest stable versions. diff --git a/features.md b/features.md new file mode 100644 index 0000000..fa248b3 --- /dev/null +++ b/features.md @@ -0,0 +1,4 @@ +- [ ] All data stored in sqlite db. +- [ ] AI generated content based on document upload +- [ ] Moderation (kick player, lock game, filter names) +- [ ] Persistent game urls while game is active diff --git a/hooks/useAuthenticatedFetch.ts b/hooks/useAuthenticatedFetch.ts new file mode 100644 index 0000000..6e96d7d --- /dev/null +++ b/hooks/useAuthenticatedFetch.ts @@ -0,0 +1,97 @@ +import { useAuth } from 'react-oidc-context'; +import { useCallback } from 'react'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +export const useAuthenticatedFetch = () => { + const auth = useAuth(); + + const isTokenExpired = useCallback(() => { + if (!auth.user?.expires_at) return true; + const expiresAt = auth.user.expires_at * 1000; + const now = Date.now(); + const bufferMs = 60 * 1000; + return now >= expiresAt - bufferMs; + }, [auth.user?.expires_at]); + + const ensureValidToken = useCallback(async (): Promise => { + if (!auth.user?.access_token) { + throw new Error('Not authenticated'); + } + + if (isTokenExpired()) { + try { + const user = await auth.signinSilent(); + if (user?.access_token) { + return user.access_token; + } + } catch { + auth.signinRedirect(); + throw new Error('Session expired, redirecting to login'); + } + } + + return auth.user.access_token; + }, [auth, isTokenExpired]); + + const authFetch = useCallback( + async (path: string, options: RequestInit = {}): Promise => { + if (!navigator.onLine) { + throw new Error('You appear to be offline. Please check your connection.'); + } + + const token = await ensureValidToken(); + const url = path.startsWith('http') ? path : `${API_URL}${path}`; + + let response: Response; + try { + response = await fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + if (!navigator.onLine) { + throw new Error('You appear to be offline. Please check your connection.'); + } + throw new Error('Network error. Please try again.'); + } + + if (response.status === 401) { + try { + const user = await auth.signinSilent(); + if (user?.access_token) { + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${user.access_token}`, + 'Content-Type': 'application/json', + }, + }); + } + } catch { + auth.signinRedirect(); + } + throw new Error('Session expired, redirecting to login'); + } + + if (response.status >= 500) { + throw new Error('Server error. Please try again later.'); + } + + return response; + }, + [auth, ensureValidToken] + ); + + return { + authFetch, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + user: auth.user, + }; +}; diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 2b9b6ac..5bda1be 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question } from '../types'; import { generateQuiz } from '../services/geminiService'; -import { POINTS_PER_QUESTION, QUESTION_TIME, PLAYER_COLORS } from '../constants'; +import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants'; import { Peer, DataConnection } from 'peerjs'; export const useGame = () => { @@ -21,6 +21,7 @@ export const useGame = () => { const [currentStreak, setCurrentStreak] = useState(0); const [currentPlayerId, setCurrentPlayerId] = useState(null); const [currentPlayerName, setCurrentPlayerName] = useState(null); + const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const timerRef = useRef | null>(null); const peerRef = useRef(null); @@ -48,6 +49,7 @@ export const useGame = () => { setError(null); setRole('HOST'); const generatedQuiz = await generateQuiz(topic); + setPendingQuizToSave({ quiz: generatedQuiz, topic }); initializeHostGame(generatedQuiz); } catch (e) { setError("Failed to generate quiz."); @@ -55,15 +57,26 @@ export const useGame = () => { } }; + const dismissSavePrompt = () => { + setPendingQuizToSave(null); + }; + const startManualCreation = () => { setRole('HOST'); setGameState('CREATING'); }; - const finalizeManualQuiz = (manualQuiz: Quiz) => { + const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => { + if (saveToLibrary) { + setPendingQuizToSave({ quiz: manualQuiz, topic: '' }); + } initializeHostGame(manualQuiz); }; + const loadSavedQuiz = (savedQuiz: Quiz) => { + initializeHostGame(savedQuiz); + }; + // We use a ref to hold the current handleHostData function // This prevents stale closures in the PeerJS event listeners const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {}); @@ -134,7 +147,7 @@ export const useGame = () => { if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return; - const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0; + const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0; const newScore = currentPlayer.score + points; setPlayers(prev => prev.map(p => { @@ -184,16 +197,14 @@ export const useGame = () => { setHasAnswered(false); setLastPointsEarned(null); setSelectedOption(null); - setTimeLeft(QUESTION_TIME); + setTimeLeft(QUESTION_TIME_MS); setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null }))); - // Use refs to get the latest state inside this async callback const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; if (currentQuiz) { const currentQ = currentQuiz.questions[currentIndex]; - // Ensure options exist const options = currentQ.options || []; const correctOpt = options.find(o => o.isCorrect); const correctShape = correctOpt?.shape || 'triangle'; @@ -201,7 +212,7 @@ export const useGame = () => { const optionsForClient = options.map(o => ({ ...o, - isCorrect: false // Masked + isCorrect: false })); broadcast({ @@ -220,17 +231,21 @@ export const useGame = () => { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { setTimeLeft(prev => { - if (prev <= 1) { endQuestion(); return 0; } - return prev - 1; + if (prev <= 100) { endQuestion(); return 0; } + return prev - 100; }); - }, 1000); + }, 100); }; const endQuestion = () => { if (timerRef.current) clearInterval(timerRef.current); setGameState('REVEAL'); broadcast({ type: 'TIME_UP', payload: {} }); - setTimeout(() => setGameState('SCOREBOARD'), 4000); + }; + + const showScoreboard = () => { + setGameState('SCOREBOARD'); + broadcast({ type: 'SHOW_SCOREBOARD', payload: { players: playersRef.current } }); }; const nextQuestion = () => { @@ -290,7 +305,7 @@ export const useGame = () => { setLastPointsEarned(null); setSelectedOption(null); setCurrentQuestionIndex(data.payload.currentQuestionIndex); - setTimeLeft(data.payload.timeLimit); + setTimeLeft(data.payload.timeLimit * 1000); setCurrentCorrectShape(data.payload.correctShape); setQuiz(prev => { @@ -308,7 +323,7 @@ export const useGame = () => { }); if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 1)), 1000); + timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); } if (data.type === 'RESULT') { @@ -343,7 +358,7 @@ export const useGame = () => { const option = arg as AnswerOption; const isCorrect = option.isCorrect; setSelectedOption(option); - const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0; + const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0; setLastPointsEarned(points); const hostPlayer = playersRef.current.find(p => p.id === 'host'); @@ -379,6 +394,7 @@ export const useGame = () => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, - startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion + pendingQuizToSave, dismissSavePrompt, + startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard }; }; \ No newline at end of file diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts new file mode 100644 index 0000000..976f82f --- /dev/null +++ b/hooks/useQuizLibrary.ts @@ -0,0 +1,216 @@ +import { useState, useCallback, useRef } from 'react'; +import toast from 'react-hot-toast'; +import { useAuthenticatedFetch } from './useAuthenticatedFetch'; +import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types'; + +interface UseQuizLibraryReturn { + quizzes: QuizListItem[]; + loading: boolean; + loadingQuizId: string | null; + deletingQuizId: string | null; + saving: boolean; + error: string | null; + fetchQuizzes: () => Promise; + loadQuiz: (id: string) => Promise; + saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise; + deleteQuiz: (id: string) => Promise; + retry: () => Promise; + clearError: () => void; +} + +export const useQuizLibrary = (): UseQuizLibraryReturn => { + const { authFetch, isAuthenticated } = useAuthenticatedFetch(); + const [quizzes, setQuizzes] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingQuizId, setLoadingQuizId] = useState(null); + const [deletingQuizId, setDeletingQuizId] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const lastOperationRef = useRef<(() => Promise) | null>(null); + + const fetchQuizzes = useCallback(async () => { + if (!isAuthenticated) return; + + setLoading(true); + setError(null); + lastOperationRef.current = fetchQuizzes; + + try { + const response = await authFetch('/api/quizzes'); + if (!response.ok) { + const errorText = response.status === 500 + ? 'Server error. Please try again.' + : 'Failed to load your quizzes.'; + throw new Error(errorText); + } + const data = await response.json(); + setQuizzes(data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load quizzes'; + setError(message); + if (!message.includes('redirecting')) { + toast.error(message); + } + } finally { + setLoading(false); + } + }, [authFetch, isAuthenticated]); + + const loadQuiz = useCallback(async (id: string): Promise => { + setLoadingQuizId(id); + setError(null); + + try { + const response = await authFetch(`/api/quizzes/${id}`); + if (!response.ok) { + const errorText = response.status === 404 + ? 'Quiz not found. It may have been deleted.' + : 'Failed to load quiz.'; + throw new Error(errorText); + } + toast.success('Quiz loaded!'); + return response.json(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load quiz'; + if (!message.includes('redirecting')) { + toast.error(message); + } + throw err; + } finally { + setLoadingQuizId(null); + } + }, [authFetch]); + + const saveQuiz = useCallback(async ( + quiz: Quiz, + source: QuizSource, + aiTopic?: string + ): Promise => { + if (saving) { + toast.error('Save already in progress'); + throw new Error('Save already in progress'); + } + + if (!quiz.title?.trim()) { + toast.error('Quiz must have a title'); + throw new Error('Quiz must have a title'); + } + if (!quiz.questions || quiz.questions.length === 0) { + toast.error('Quiz must have at least one question'); + throw new Error('Quiz must have at least one question'); + } + for (const q of quiz.questions) { + if (!q.text?.trim()) { + toast.error('All questions must have text'); + throw new Error('All questions must have text'); + } + if (!q.options || q.options.length < 2) { + toast.error('Each question must have at least 2 options'); + throw new Error('Each question must have at least 2 options'); + } + const hasCorrect = q.options.some(o => o.isCorrect); + if (!hasCorrect) { + toast.error('Each question must have a correct answer'); + throw new Error('Each question must have a correct answer'); + } + } + + setSaving(true); + setError(null); + + try { + const response = await authFetch('/api/quizzes', { + method: 'POST', + body: JSON.stringify({ + title: quiz.title, + source, + aiTopic, + questions: quiz.questions.map(q => ({ + text: q.text, + timeLimit: q.timeLimit, + options: q.options.map(o => ({ + text: o.text, + isCorrect: o.isCorrect, + shape: o.shape, + color: o.color, + reason: o.reason, + })), + })), + }), + }); + + if (!response.ok) { + const errorText = response.status === 400 + ? 'Invalid quiz data. Please check and try again.' + : 'Failed to save quiz.'; + throw new Error(errorText); + } + + const data = await response.json(); + toast.success('Quiz saved to your library!'); + return data.id; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save quiz'; + if (!message.includes('redirecting')) { + toast.error(message); + } + throw err; + } finally { + setSaving(false); + } + }, [authFetch]); + + const deleteQuiz = useCallback(async (id: string): Promise => { + setDeletingQuizId(id); + setError(null); + + try { + const response = await authFetch(`/api/quizzes/${id}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + const errorText = response.status === 404 + ? 'Quiz not found.' + : 'Failed to delete quiz.'; + throw new Error(errorText); + } + + setQuizzes(prev => prev.filter(q => q.id !== id)); + toast.success('Quiz deleted'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete quiz'; + if (!message.includes('redirecting')) { + toast.error(message); + } + throw err; + } finally { + setDeletingQuizId(null); + } + }, [authFetch]); + + const retry = useCallback(async () => { + if (lastOperationRef.current) { + await lastOperationRef.current(); + } + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + return { + quizzes, + loading, + loadingQuizId, + deletingQuizId, + saving, + error, + fetchQuizzes, + loadQuiz, + saveQuiz, + deleteQuiz, + retry, + clearError, + }; +}; diff --git a/index.tsx b/index.tsx index 6ca5361..256ee3f 100644 --- a/index.tsx +++ b/index.tsx @@ -1,15 +1,55 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { AuthProvider } from 'react-oidc-context'; +import { Toaster } from 'react-hot-toast'; import App from './App'; +import { oidcConfig } from './src/config/oidc'; const rootElement = document.getElementById('root'); if (!rootElement) { throw new Error("Could not find root element to mount to"); } +const onSigninCallback = () => { + window.history.replaceState({}, document.title, window.location.pathname); +}; + const root = ReactDOM.createRoot(rootElement); root.render( - + { + window.localStorage.clear(); + }} + > + + + ); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7434bf8..f223115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,12 @@ "canvas-confetti": "^1.9.4", "framer-motion": "^12.26.1", "lucide-react": "^0.562.0", + "oidc-client-ts": "^3.1.0", "peerjs": "^1.5.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-hot-toast": "^2.6.0", + "react-oidc-context": "^3.2.0", "recharts": "^3.6.0", "uuid": "^13.0.0" }, @@ -1622,6 +1625,13 @@ "node": ">= 8" } }, + "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==", + "license": "MIT", + "peer": true + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -2047,6 +2057,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/google-auth-library": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", @@ -2212,6 +2231,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2340,6 +2368,19 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2482,6 +2523,23 @@ "react": "^19.2.3" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", @@ -2489,6 +2547,19 @@ "license": "MIT", "peer": true }, + "node_modules/react-oidc-context": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", + "integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", diff --git a/package.json b/package.json index 1c0f6eb..be6e5d3 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,18 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.2.3", "@google/genai": "^1.35.0", - "lucide-react": "^0.562.0", - "react-dom": "^19.2.3", - "uuid": "^13.0.0", - "recharts": "^3.6.0", - "framer-motion": "^12.26.1", "canvas-confetti": "^1.9.4", - "peerjs": "^1.5.2" + "framer-motion": "^12.26.1", + "lucide-react": "^0.562.0", + "oidc-client-ts": "^3.1.0", + "peerjs": "^1.5.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-hot-toast": "^2.6.0", + "react-oidc-context": "^3.2.0", + "recharts": "^3.6.0", + "uuid": "^13.0.0" }, "devDependencies": { "@types/node": "^22.14.0", diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..6331971 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +ENV_FILE=".env" +ENV_EXAMPLE=".env.example" + +echo "Kaboot Setup Script" +echo "===================" +echo "" + +if [ -f "$ENV_FILE" ]; then + read -p ".env file already exists. Overwrite? (y/N): " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborting. Existing .env file preserved." + exit 0 + fi +fi + +if [ ! -f "$ENV_EXAMPLE" ]; then + echo "Error: .env.example not found. Run this script from the project root." + exit 1 +fi + +echo "Generating secrets..." + +PG_PASS=$(openssl rand -base64 36 | tr -d '\n') +AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n') + +cp "$ENV_EXAMPLE" "$ENV_FILE" + +if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE" + sed -i '' "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE" +else + sed -i "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE" + sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE" +fi + +echo "" +echo "Created .env file with generated secrets." +echo "" +echo "Next steps:" +echo " 1. Review .env and adjust settings if needed" +echo " 2. Run: docker compose up -d" +echo " 3. Open: http://localhost:9000/if/flow/initial-setup/" +echo " 4. Follow docs/AUTHENTIK_SETUP.md to configure the OAuth2 provider" +echo "" diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..3910563 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.env* diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..26de127 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine + +WORKDIR /app + +RUN apk add --no-cache python3 make g++ + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build && cp src/db/schema.sql dist/db/ + +RUN mkdir -p /data + +EXPOSE 3001 + +CMD ["npm", "start"] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..4ea764d --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2241 @@ +{ + "name": "kaboot-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaboot-backend", + "version": "1.0.0", + "dependencies": { + "better-sqlite3": "^11.7.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", + "uuid": "^11.0.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.7", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..8ba79da --- /dev/null +++ b/server/package.json @@ -0,0 +1,31 @@ +{ + "name": "kaboot-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "tsx --env-file=.env.test tests/run-tests.ts", + "test:only": "tsx --env-file=.env.test tests/api.test.ts", + "test:get-token": "tsx --env-file=.env.test tests/get-token.ts" + }, + "dependencies": { + "better-sqlite3": "^11.7.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", + "uuid": "^11.0.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.7", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts new file mode 100644 index 0000000..b7bfc69 --- /dev/null +++ b/server/src/db/connection.ts @@ -0,0 +1,19 @@ +import Database, { Database as DatabaseType } from 'better-sqlite3'; +import { readFileSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DATABASE_PATH || join(__dirname, '../../../data/kaboot.db'); + +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db: DatabaseType = new Database(DB_PATH); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8'); +db.exec(schema); + +console.log(`Database initialized at ${DB_PATH}`); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql new file mode 100644 index 0000000..9e4c0dc --- /dev/null +++ b/server/src/db/schema.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + email TEXT, + display_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME +); + +CREATE TABLE IF NOT EXISTS quizzes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')), + ai_topic TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + quiz_id TEXT NOT NULL, + text TEXT NOT NULL, + time_limit INTEGER DEFAULT 20, + order_index INTEGER NOT NULL, + FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS answer_options ( + id TEXT PRIMARY KEY, + question_id TEXT NOT NULL, + text TEXT NOT NULL, + is_correct INTEGER NOT NULL, + shape TEXT NOT NULL CHECK(shape IN ('triangle', 'diamond', 'circle', 'square')), + color TEXT NOT NULL CHECK(color IN ('red', 'blue', 'yellow', 'green')), + reason TEXT, + order_index INTEGER NOT NULL, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id); +CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id); +CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id); diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..4557eba --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,77 @@ +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import { db } from './db/connection.js'; +import quizzesRouter from './routes/quizzes.js'; +import usersRouter from './routes/users.js'; + +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, +})); + +const LOG_REQUESTS = process.env.LOG_REQUESTS === 'true'; + +app.use((req: Request, res: Response, next: NextFunction) => { + if (LOG_REQUESTS && req.path !== '/health') { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`); + }); + } + next(); +}); + +app.use((req: Request, res: Response, next: NextFunction) => { + express.json({ limit: '10mb' })(req, res, (err) => { + if (err instanceof SyntaxError && 'body' in err) { + res.status(400).json({ error: 'Invalid JSON' }); + return; + } + if (err) { + next(err); + return; + } + next(); + }); +}); + +app.get('/health', (_req: Request, res: Response) => { + try { + db.prepare('SELECT 1').get(); + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + database: 'connected' + }); + } catch { + res.status(503).json({ + status: 'error', + timestamp: new Date().toISOString(), + database: 'disconnected' + }); + } +}); + +app.use('/api/quizzes', quizzesRouter); +app.use('/api/users', usersRouter); + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(PORT, () => { + console.log(`Kaboot backend running on port ${PORT}`); + console.log(`Database: ${process.env.DATABASE_PATH || 'default location'}`); + console.log(`CORS origin: ${process.env.CORS_ORIGIN || 'http://localhost:5173'}`); +}); + +process.on('SIGTERM', () => { + console.log('Shutting down...'); + db.close(); + process.exit(0); +}); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..a5aa677 --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,82 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; + +const OIDC_ISSUER = process.env.OIDC_ISSUER || 'http://localhost:9000/application/o/kaboot/'; +const OIDC_JWKS_URI = process.env.OIDC_JWKS_URI || 'http://localhost:9000/application/o/kaboot/jwks/'; +const OIDC_INTERNAL_JWKS_URI = process.env.OIDC_INTERNAL_JWKS_URI || OIDC_JWKS_URI; + +const client = jwksClient({ + jwksUri: OIDC_INTERNAL_JWKS_URI, + cache: true, + cacheMaxAge: 600000, + rateLimit: true, + jwksRequestsPerMinute: 10, +}); + +function getSigningKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void { + if (!header.kid) { + callback(new Error('No kid in token header')); + return; + } + client.getSigningKey(header.kid, (err, key) => { + if (err) { + callback(err); + return; + } + const signingKey = key?.getPublicKey(); + callback(null, signingKey); + }); +} + +export interface AuthenticatedUser { + sub: string; + preferred_username: string; + email?: string; + name?: string; +} + +export interface AuthenticatedRequest extends Request { + user?: AuthenticatedUser; +} + +export function requireAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + + const token = authHeader.slice(7); + + jwt.verify( + token, + getSigningKey, + { + issuer: OIDC_ISSUER, + algorithms: ['RS256'], + }, + (err, decoded) => { + if (err) { + console.error('Token verification failed:', err.message); + res.status(401).json({ error: 'Invalid token', details: err.message }); + return; + } + + const payload = decoded as jwt.JwtPayload; + req.user = { + sub: payload.sub!, + preferred_username: payload.preferred_username || payload.sub!, + email: payload.email, + name: payload.name, + }; + + next(); + } + ); +} diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts new file mode 100644 index 0000000..25d049a --- /dev/null +++ b/server/src/routes/quizzes.ts @@ -0,0 +1,285 @@ +import { Router, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { db } from '../db/connection.js'; +import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; + +const router = Router(); + +router.use(requireAuth); + +interface QuizBody { + title: string; + source: 'manual' | 'ai_generated'; + aiTopic?: string; + questions: { + text: string; + timeLimit?: number; + options: { + text: string; + isCorrect: boolean; + shape: string; + color: string; + reason?: string; + }[]; + }[]; +} + +router.get('/', (req: AuthenticatedRequest, res: Response) => { + const quizzes = db.prepare(` + SELECT + q.id, + q.title, + q.source, + q.ai_topic as aiTopic, + q.created_at as createdAt, + q.updated_at as updatedAt, + (SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount + FROM quizzes q + WHERE q.user_id = ? + ORDER BY q.updated_at DESC + `).all(req.user!.sub); + + res.json(quizzes); +}); + +router.get('/:id', (req: AuthenticatedRequest, res: Response) => { + const quiz = db.prepare(` + SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt + FROM quizzes + WHERE id = ? AND user_id = ? + `).get(req.params.id, req.user!.sub) as Record | undefined; + + if (!quiz) { + res.status(404).json({ error: 'Quiz not found' }); + return; + } + + const questions = db.prepare(` + SELECT id, text, time_limit as timeLimit, order_index as orderIndex + FROM questions + WHERE quiz_id = ? + ORDER BY order_index + `).all(quiz.id) as Record[]; + + const questionsWithOptions = questions.map((q) => { + const options = db.prepare(` + SELECT id, text, is_correct as isCorrect, shape, color, reason, order_index as orderIndex + FROM answer_options + WHERE question_id = ? + ORDER BY order_index + `).all(q.id) as Record[]; + + return { + ...q, + options: options.map((o) => ({ + ...o, + isCorrect: Boolean(o.isCorrect), + })), + }; + }); + + res.json({ + ...quiz, + questions: questionsWithOptions, + }); +}); + +function validateQuizBody(body: QuizBody): string | null { + const { title, source, questions } = body; + + if (!title?.trim()) { + return 'Title is required and cannot be empty'; + } + + if (!source || !['manual', 'ai_generated'].includes(source)) { + return 'Source must be "manual" or "ai_generated"'; + } + + if (!questions || !Array.isArray(questions) || questions.length === 0) { + return 'At least one question is required'; + } + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + if (!q.text?.trim()) { + return `Question ${i + 1} text is required`; + } + if (!q.options || !Array.isArray(q.options) || q.options.length < 2) { + return `Question ${i + 1} must have at least 2 options`; + } + const hasCorrect = q.options.some(o => o.isCorrect); + if (!hasCorrect) { + return `Question ${i + 1} must have at least one correct answer`; + } + } + + return null; +} + +router.post('/', (req: AuthenticatedRequest, res: Response) => { + const body = req.body as QuizBody; + const { title, source, aiTopic, questions } = body; + + const validationError = validateQuizBody(body); + if (validationError) { + res.status(400).json({ error: validationError }); + return; + } + + const quizId = uuidv4(); + + const upsertUser = db.prepare(` + INSERT INTO users (id, username, email, display_name, last_login) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + last_login = CURRENT_TIMESTAMP, + email = COALESCE(excluded.email, users.email), + display_name = COALESCE(excluded.display_name, users.display_name) + `); + + const insertQuiz = db.prepare(` + INSERT INTO quizzes (id, user_id, title, source, ai_topic) + VALUES (?, ?, ?, ?, ?) + `); + + const insertQuestion = db.prepare(` + INSERT INTO questions (id, quiz_id, text, time_limit, order_index) + VALUES (?, ?, ?, ?, ?) + `); + + const insertOption = db.prepare(` + INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = db.transaction(() => { + upsertUser.run( + req.user!.sub, + req.user!.preferred_username, + req.user!.email || null, + req.user!.name || null + ); + + insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null); + + questions.forEach((q, qIdx) => { + const questionId = uuidv4(); + insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx); + + q.options.forEach((o, oIdx) => { + insertOption.run( + uuidv4(), + questionId, + o.text, + o.isCorrect ? 1 : 0, + o.shape, + o.color, + o.reason || null, + oIdx + ); + }); + }); + }); + + transaction(); + res.status(201).json({ id: quizId }); +}); + +router.put('/:id', (req: AuthenticatedRequest, res: Response) => { + const body = req.body as QuizBody; + const { title, questions } = body; + const quizId = req.params.id; + + if (!title?.trim()) { + res.status(400).json({ error: 'Title is required and cannot be empty' }); + return; + } + + if (!questions || !Array.isArray(questions) || questions.length === 0) { + res.status(400).json({ error: 'At least one question is required' }); + return; + } + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + if (!q.text?.trim()) { + res.status(400).json({ error: `Question ${i + 1} text is required` }); + return; + } + if (!q.options || !Array.isArray(q.options) || q.options.length < 2) { + res.status(400).json({ error: `Question ${i + 1} must have at least 2 options` }); + return; + } + const hasCorrect = q.options.some(o => o.isCorrect); + if (!hasCorrect) { + res.status(400).json({ error: `Question ${i + 1} must have at least one correct answer` }); + return; + } + } + + const existing = db.prepare(` + SELECT id FROM quizzes WHERE id = ? AND user_id = ? + `).get(quizId, req.user!.sub); + + if (!existing) { + res.status(404).json({ error: 'Quiz not found' }); + return; + } + + const updateQuiz = db.prepare(` + UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `); + + const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`); + + const insertQuestion = db.prepare(` + INSERT INTO questions (id, quiz_id, text, time_limit, order_index) + VALUES (?, ?, ?, ?, ?) + `); + + const insertOption = db.prepare(` + INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = db.transaction(() => { + updateQuiz.run(title, quizId); + deleteQuestions.run(quizId); + + questions.forEach((q, qIdx) => { + const questionId = uuidv4(); + insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx); + + q.options.forEach((o, oIdx) => { + insertOption.run( + uuidv4(), + questionId, + o.text, + o.isCorrect ? 1 : 0, + o.shape, + o.color, + o.reason || null, + oIdx + ); + }); + }); + }); + + transaction(); + res.json({ id: quizId }); +}); + +router.delete('/:id', (req: AuthenticatedRequest, res: Response) => { + const result = db.prepare(` + DELETE FROM quizzes WHERE id = ? AND user_id = ? + `).run(req.params.id, req.user!.sub); + + if (result.changes === 0) { + res.status(404).json({ error: 'Quiz not found' }); + return; + } + + res.status(204).send(); +}); + +export default router; diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts new file mode 100644 index 0000000..9e4ece6 --- /dev/null +++ b/server/src/routes/users.ts @@ -0,0 +1,32 @@ +import { Router, Response } from 'express'; +import { db } from '../db/connection.js'; +import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; + +const router = Router(); + +router.use(requireAuth); + +router.get('/me', (req: AuthenticatedRequest, res: Response) => { + const user = db.prepare(` + SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin + FROM users + WHERE id = ? + `).get(req.user!.sub) as Record | undefined; + + if (!user) { + res.json({ + id: req.user!.sub, + username: req.user!.preferred_username, + email: req.user!.email, + displayName: req.user!.name, + createdAt: null, + lastLogin: null, + isNew: true, + }); + return; + } + + res.json({ ...user, isNew: false }); +}); + +export default router; diff --git a/server/tests/README.md b/server/tests/README.md new file mode 100644 index 0000000..8ce148b --- /dev/null +++ b/server/tests/README.md @@ -0,0 +1,68 @@ +# Kaboot Backend API Tests + +## Getting a Test Token + +Since Authentik uses OAuth2 flows that require browser interaction, you need to obtain a token manually. + +### Method 1: Browser DevTools (Easiest) + +1. Start the Kaboot frontend: `npm run dev` (in root directory) +2. Open `http://localhost:5173` +3. Click "Sign In" and log in with Authentik +4. Open browser DevTools (F12) +5. Go to **Application** > **Local Storage** > `http://localhost:5173` +6. Find the key starting with `oidc.user:` +7. Click on it and find `"access_token"` in the JSON value +8. Copy the token value (without quotes) + +### Method 2: Service Account + +1. Go to Authentik Admin: `http://localhost:9000/if/admin/` +2. Navigate to **Directory** > **Users** +3. Click **Create Service Account** +4. Enter a name (e.g., `kaboot-test-service`) +5. Note the generated username and token +6. Use these credentials: + ```bash + TEST_USERNAME= \ + TEST_PASSWORD= \ + npm run test:get-token + ``` + +## Running Tests + +```bash +cd server +npm install + +# Set the token you obtained +export TEST_TOKEN="your-access-token-here" + +# Run tests +npm run test +``` + +## Test Coverage + +The test suite covers: + +- **Health Check**: Basic server availability +- **Authentication**: 401 without token, 401 with invalid token +- **User API**: GET /api/users/me +- **Quiz CRUD**: + - GET /api/quizzes (list) + - POST /api/quizzes (create) + - GET /api/quizzes/:id (read) + - PUT /api/quizzes/:id (update) + - DELETE /api/quizzes/:id (delete) + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `API_URL` | `http://localhost:3001` | Backend API URL | +| `TEST_TOKEN` | (required) | JWT access token from Authentik | +| `AUTHENTIK_URL` | `http://localhost:9000` | Authentik server URL | +| `CLIENT_ID` | `kaboot-spa` | OAuth2 client ID | +| `TEST_USERNAME` | `kaboottest` | Username for token request | +| `TEST_PASSWORD` | `kaboottest` | Password for token request | diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts new file mode 100644 index 0000000..4d108a2 --- /dev/null +++ b/server/tests/api.test.ts @@ -0,0 +1,1015 @@ +const API_URL = process.env.API_URL || 'http://localhost:3001'; +const TOKEN = process.env.TEST_TOKEN; + +if (!TOKEN) { + console.error('ERROR: TEST_TOKEN environment variable is required'); + console.log('Run: npm run test:get-token'); + console.log('Then: export TEST_TOKEN=""'); + process.exit(1); +} + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +async function request( + method: string, + path: string, + body?: unknown, + expectStatus = 200 +): Promise<{ status: number; data: unknown }> { + const response = await fetch(`${API_URL}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TOKEN}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const data = response.headers.get('content-type')?.includes('application/json') + ? await response.json() + : null; + + if (response.status !== expectStatus) { + throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`); + } + + return { status: response.status, data }; +} + +async function test(name: string, fn: () => Promise) { + try { + await fn(); + results.push({ name, passed: true }); + console.log(` ✓ ${name}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.push({ name, passed: false, error: message }); + console.log(` ✗ ${name}`); + console.log(` ${message}`); + } +} + +async function runTests() { + console.log('\n=== Kaboot API Tests ===\n'); + console.log(`API: ${API_URL}`); + console.log(''); + + let createdQuizId: string | null = null; + + console.log('Health Check:'); + await test('GET /health returns ok', async () => { + const res = await fetch(`${API_URL}/health`); + const data = await res.json(); + if (data.status !== 'ok') throw new Error('Health check failed'); + }); + + console.log('\nAuth Tests:'); + await test('GET /api/quizzes without token returns 401', async () => { + const res = await fetch(`${API_URL}/api/quizzes`); + if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); + }); + + await test('GET /api/quizzes with invalid token returns 401', async () => { + const res = await fetch(`${API_URL}/api/quizzes`, { + headers: { Authorization: 'Bearer invalid-token' }, + }); + if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); + }); + + console.log('\nUser Tests:'); + await test('GET /api/users/me returns user info', async () => { + const { data } = await request('GET', '/api/users/me'); + const user = data as Record; + if (!user.id) throw new Error('Missing user id'); + if (!user.username) throw new Error('Missing username'); + }); + + console.log('\nQuiz CRUD Tests:'); + await test('GET /api/quizzes returns array', async () => { + const { data } = await request('GET', '/api/quizzes'); + if (!Array.isArray(data)) throw new Error('Expected array'); + }); + + await test('POST /api/quizzes creates quiz', async () => { + const quiz = { + title: 'Test Quiz', + source: 'manual', + questions: [ + { + text: 'What is 2 + 2?', + timeLimit: 20, + options: [ + { text: '3', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: '5', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: '6', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const result = data as { id: string }; + if (!result.id) throw new Error('Missing quiz id'); + createdQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns full quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + const { data } = await request('GET', `/api/quizzes/${createdQuizId}`); + const quiz = data as Record; + if (quiz.title !== 'Test Quiz') throw new Error('Wrong title'); + if (!Array.isArray(quiz.questions)) throw new Error('Missing questions'); + const questions = quiz.questions as Record[]; + if (questions.length !== 1) throw new Error('Wrong question count'); + const q = questions[0]; + if (!Array.isArray(q.options)) throw new Error('Missing options'); + if ((q.options as unknown[]).length !== 4) throw new Error('Wrong option count'); + }); + + await test('PUT /api/quizzes/:id updates quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + const updatedQuiz = { + title: 'Updated Test Quiz', + questions: [ + { + text: 'Updated question?', + timeLimit: 30, + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'D', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + await request('PUT', `/api/quizzes/${createdQuizId}`, updatedQuiz); + + const { data } = await request('GET', `/api/quizzes/${createdQuizId}`); + const quiz = data as Record; + if (quiz.title !== 'Updated Test Quiz') throw new Error('Title not updated'); + }); + + await test('DELETE /api/quizzes/:id deletes quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + await request('DELETE', `/api/quizzes/${createdQuizId}`, undefined, 204); + }); + + await test('GET /api/quizzes/:id returns 404 for deleted quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404); + }); + + console.log('\nQuiz Validation Tests:'); + await test('POST /api/quizzes without title returns 400', async () => { + const invalidQuiz = { + source: 'manual', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes without source returns 400', async () => { + const invalidQuiz = { + title: 'Missing Source Quiz', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes without questions returns 400', async () => { + const invalidQuiz = { + title: 'No Questions Quiz', + source: 'manual', + questions: [], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with empty body returns 400', async () => { + await request('POST', '/api/quizzes', {}, 400); + }); + + console.log('\nQuiz Not Found Tests:'); + await test('GET /api/quizzes/:id with non-existent ID returns 404', async () => { + await request('GET', '/api/quizzes/non-existent-uuid-12345', undefined, 404); + }); + + await test('PUT /api/quizzes/:id with non-existent ID returns 404', async () => { + const quiz = { + title: 'Update Non-Existent', + questions: [ + { + text: 'Q?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + await request('PUT', '/api/quizzes/non-existent-uuid-12345', quiz, 404); + }); + + await test('DELETE /api/quizzes/:id with non-existent ID returns 404', async () => { + await request('DELETE', '/api/quizzes/non-existent-uuid-12345', undefined, 404); + }); + + console.log('\nQuiz Source Types Tests:'); + let aiQuizId: string | null = null; + + await test('POST /api/quizzes with ai_generated source and aiTopic', async () => { + const aiQuiz = { + title: 'AI Generated Quiz', + source: 'ai_generated', + aiTopic: 'Space Exploration', + questions: [ + { + text: 'What planet is known as the Red Planet?', + timeLimit: 20, + options: [ + { text: 'Venus', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'Mars', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: 'Jupiter', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'Saturn', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); + const result = data as { id: string }; + if (!result.id) throw new Error('Missing quiz id'); + aiQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns aiTopic for AI quiz', async () => { + if (!aiQuizId) throw new Error('No AI quiz created'); + const { data } = await request('GET', `/api/quizzes/${aiQuizId}`); + const quiz = data as Record; + if (quiz.source !== 'ai_generated') throw new Error('Wrong source'); + if (quiz.aiTopic !== 'Space Exploration') throw new Error('Missing or wrong aiTopic'); + }); + + await test('GET /api/quizzes list includes source and questionCount', async () => { + const { data } = await request('GET', '/api/quizzes'); + const quizzes = data as Record[]; + if (quizzes.length === 0) throw new Error('Expected at least one quiz'); + const quiz = quizzes.find((q) => q.id === aiQuizId); + if (!quiz) throw new Error('AI quiz not in list'); + if (quiz.source !== 'ai_generated') throw new Error('Missing source in list'); + if (typeof quiz.questionCount !== 'number') throw new Error('Missing questionCount'); + if (quiz.questionCount !== 1) throw new Error('Wrong questionCount'); + }); + + await test('DELETE cleanup AI quiz', async () => { + if (!aiQuizId) throw new Error('No AI quiz to delete'); + await request('DELETE', `/api/quizzes/${aiQuizId}`, undefined, 204); + }); + + console.log('\nQuiz with Multiple Questions Tests:'); + let multiQuizId: string | null = null; + + await test('POST /api/quizzes with multiple questions', async () => { + const multiQuiz = { + title: 'Multi-Question Quiz', + source: 'manual', + questions: [ + { + text: 'Question 1?', + timeLimit: 15, + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + { + text: 'Question 2?', + timeLimit: 25, + options: [ + { text: 'X', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'Y', isCorrect: true, shape: 'square', color: 'green' }, + ], + }, + { + text: 'Question 3?', + timeLimit: 30, + options: [ + { text: 'P', isCorrect: false, shape: 'triangle', color: 'red', reason: 'Wrong because...' }, + { text: 'Q', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct because...' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', multiQuiz, 201); + const result = data as { id: string }; + multiQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns all questions with correct order', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz created'); + const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); + const quiz = data as { questions: { text: string; timeLimit: number; options: { reason?: string }[] }[] }; + if (quiz.questions.length !== 3) throw new Error(`Expected 3 questions, got ${quiz.questions.length}`); + if (quiz.questions[0].text !== 'Question 1?') throw new Error('Wrong order for Q1'); + if (quiz.questions[1].text !== 'Question 2?') throw new Error('Wrong order for Q2'); + if (quiz.questions[2].text !== 'Question 3?') throw new Error('Wrong order for Q3'); + if (quiz.questions[0].timeLimit !== 15) throw new Error('Wrong timeLimit for Q1'); + if (quiz.questions[2].options[1].reason !== 'Correct because...') throw new Error('Missing reason field'); + }); + + await test('GET /api/quizzes shows correct questionCount for multi-question quiz', async () => { + const { data } = await request('GET', '/api/quizzes'); + const quizzes = data as Record[]; + const quiz = quizzes.find((q) => q.id === multiQuizId); + if (!quiz) throw new Error('Multi-question quiz not in list'); + if (quiz.questionCount !== 3) throw new Error(`Expected questionCount 3, got ${quiz.questionCount}`); + }); + + await test('PUT /api/quizzes/:id replaces all questions', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz created'); + const updatedQuiz = { + title: 'Updated Multi Quiz', + questions: [ + { + text: 'Only One Question Now', + timeLimit: 10, + options: [ + { text: 'Solo', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'Duo', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + await request('PUT', `/api/quizzes/${multiQuizId}`, updatedQuiz); + const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); + const quiz = data as { title: string; questions: unknown[] }; + if (quiz.title !== 'Updated Multi Quiz') throw new Error('Title not updated'); + if (quiz.questions.length !== 1) throw new Error(`Expected 1 question after update, got ${quiz.questions.length}`); + }); + + await test('DELETE cleanup multi-question quiz', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz to delete'); + await request('DELETE', `/api/quizzes/${multiQuizId}`, undefined, 204); + }); + + console.log('\nTimestamp Tests:'); + let timestampQuizId: string | null = null; + + await test('POST /api/quizzes returns quiz with timestamps', async () => { + const quiz = { + title: 'Timestamp Test Quiz', + source: 'manual', + questions: [ + { + text: 'Timestamp Q?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data: createData } = await request('POST', '/api/quizzes', quiz, 201); + timestampQuizId = (createData as { id: string }).id; + + const { data } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const result = data as Record; + if (!result.createdAt) throw new Error('Missing createdAt'); + if (!result.updatedAt) throw new Error('Missing updatedAt'); + }); + + await test('PUT /api/quizzes/:id updates updatedAt timestamp', async () => { + if (!timestampQuizId) throw new Error('No timestamp quiz created'); + + const { data: beforeData } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const beforeUpdatedAt = (beforeData as Record).updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + await request('PUT', `/api/quizzes/${timestampQuizId}`, { + title: 'Updated Timestamp Quiz', + questions: [ + { + text: 'Updated Q?', + options: [ + { text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' }, + ], + }, + ], + }); + + const { data: afterData } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const afterUpdatedAt = (afterData as Record).updatedAt; + + if (beforeUpdatedAt === afterUpdatedAt) throw new Error('updatedAt should have changed'); + }); + + await test('DELETE cleanup timestamp quiz', async () => { + if (!timestampQuizId) throw new Error('No timestamp quiz to delete'); + await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204); + }); + + console.log('\nSave Integration Tests (Phase 5):'); + let manualSaveQuizId: string | null = null; + let aiSaveQuizId: string | null = null; + + await test('POST /api/quizzes manual quiz without aiTopic', async () => { + const manualQuiz = { + title: 'Manual Save Test Quiz', + source: 'manual', + questions: [ + { + text: 'What is 1+1?', + timeLimit: 20, + options: [ + { text: '1', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: '2', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: '3', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: '4', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', manualQuiz, 201); + const result = data as { id: string }; + manualSaveQuizId = result.id; + }); + + await test('GET manual quiz has null aiTopic', async () => { + if (!manualSaveQuizId) throw new Error('No manual quiz created'); + const { data } = await request('GET', `/api/quizzes/${manualSaveQuizId}`); + const quiz = data as Record; + if (quiz.source !== 'manual') throw new Error('Wrong source'); + if (quiz.aiTopic !== null) throw new Error(`Expected null aiTopic, got ${quiz.aiTopic}`); + }); + + await test('POST /api/quizzes ai_generated with empty aiTopic treated as null', async () => { + const aiQuiz = { + title: 'AI Quiz Empty Topic', + source: 'ai_generated', + aiTopic: '', + questions: [ + { + text: 'Test?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); + aiSaveQuizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${aiSaveQuizId}`); + const quiz = getResult as Record; + if (quiz.aiTopic !== null && quiz.aiTopic !== '') { + throw new Error(`Expected null/empty aiTopic for empty string, got ${quiz.aiTopic}`); + } + }); + + await test('DELETE cleanup manual save quiz', async () => { + if (manualSaveQuizId) { + await request('DELETE', `/api/quizzes/${manualSaveQuizId}`, undefined, 204); + } + }); + + await test('DELETE cleanup ai save quiz', async () => { + if (aiSaveQuizId) { + await request('DELETE', `/api/quizzes/${aiSaveQuizId}`, undefined, 204); + } + }); + + console.log('\nOption Preservation Tests:'); + let optionQuizId: string | null = null; + + await test('POST /api/quizzes preserves all option fields including reason', async () => { + const quizWithReasons = { + title: 'Quiz With Reasons', + source: 'manual', + questions: [ + { + text: 'Capital of France?', + timeLimit: 15, + options: [ + { text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is the capital of UK' }, + { text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct! Paris is the capital of France' }, + { text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow', reason: 'Berlin is the capital of Germany' }, + { text: 'Madrid', isCorrect: false, shape: 'square', color: 'green', reason: 'Madrid is the capital of Spain' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quizWithReasons, 201); + optionQuizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${optionQuizId}`); + const quiz = getResult as { questions: { options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }[] }; + + const options = quiz.questions[0].options; + if (options.length !== 4) throw new Error('Expected 4 options'); + + const parisOpt = options.find(o => o.text === 'Paris'); + if (!parisOpt) throw new Error('Paris option not found'); + if (!parisOpt.isCorrect) throw new Error('Paris should be correct'); + if (parisOpt.reason !== 'Correct! Paris is the capital of France') throw new Error('Paris reason not preserved'); + + const londonOpt = options.find(o => o.text === 'London'); + if (!londonOpt) throw new Error('London option not found'); + if (londonOpt.isCorrect) throw new Error('London should not be correct'); + if (londonOpt.reason !== 'London is the capital of UK') throw new Error('London reason not preserved'); + }); + + await test('POST /api/quizzes options without reason field are preserved', async () => { + const quizNoReasons = { + title: 'Quiz Without Reasons', + source: 'ai_generated', + aiTopic: 'Geography', + questions: [ + { + text: 'Largest ocean?', + options: [ + { text: 'Atlantic', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'Pacific', isCorrect: true, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quizNoReasons, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + const quiz = getResult as { questions: { options: { reason?: string }[] }[] }; + + const options = quiz.questions[0].options; + const pacificOpt = options.find((o: any) => o.text === 'Pacific'); + if (pacificOpt?.reason !== null && pacificOpt?.reason !== undefined) { + throw new Error('Expected null/undefined reason for option without reason'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('DELETE cleanup option quiz', async () => { + if (optionQuizId) { + await request('DELETE', `/api/quizzes/${optionQuizId}`, undefined, 204); + } + }); + + console.log('\nConcurrent Save Tests:'); + + await test('Multiple quizzes can be saved by same user', async () => { + const quiz1 = { + title: 'Concurrent Quiz 1', + source: 'manual', + questions: [{ text: 'Q1?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], + }; + const quiz2 = { + title: 'Concurrent Quiz 2', + source: 'ai_generated', + aiTopic: 'Science', + questions: [{ text: 'Q2?', options: [{ text: 'C', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'D', isCorrect: false, shape: 'square', color: 'green' }] }], + }; + + const [res1, res2] = await Promise.all([ + request('POST', '/api/quizzes', quiz1, 201), + request('POST', '/api/quizzes', quiz2, 201), + ]); + + const id1 = (res1.data as { id: string }).id; + const id2 = (res2.data as { id: string }).id; + + if (id1 === id2) throw new Error('Quiz IDs should be unique'); + + const { data: listData } = await request('GET', '/api/quizzes'); + const list = listData as { id: string; title: string }[]; + + const found1 = list.find(q => q.id === id1); + const found2 = list.find(q => q.id === id2); + + if (!found1) throw new Error('Quiz 1 not in list'); + if (!found2) throw new Error('Quiz 2 not in list'); + + await Promise.all([ + request('DELETE', `/api/quizzes/${id1}`, undefined, 204), + request('DELETE', `/api/quizzes/${id2}`, undefined, 204), + ]); + }); + + console.log('\nEdge Case Tests:'); + + await test('POST /api/quizzes with very long title', async () => { + const longTitle = 'A'.repeat(500); + const quiz = { + title: longTitle, + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + if ((getResult as { title: string }).title !== longTitle) { + throw new Error('Long title not preserved'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with special characters in title', async () => { + const specialTitle = 'Quiz with "quotes" & and emoji test'; + const quiz = { + title: specialTitle, + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + if ((getResult as { title: string }).title !== specialTitle) { + throw new Error('Special characters not preserved'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with whitespace-only title returns 400', async () => { + const quiz = { + title: ' ', + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], + }; + + await request('POST', '/api/quizzes', quiz, 400); + }); + + console.log('\nPhase 6 - Error Handling Tests:'); + + await test('POST /api/quizzes with question without text returns 400', async () => { + const invalidQuiz = { + title: 'Quiz with empty question', + source: 'manual', + questions: [ + { + text: '', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with question with only one option returns 400', async () => { + const invalidQuiz = { + title: 'Quiz with single option', + source: 'manual', + questions: [ + { + text: 'Question with one option?', + options: [ + { text: 'Only one', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with question with no correct answer returns 400', async () => { + const invalidQuiz = { + title: 'Quiz with no correct answer', + source: 'manual', + questions: [ + { + text: 'Question with no correct?', + options: [ + { text: 'A', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with invalid source type returns 400', async () => { + const invalidQuiz = { + title: 'Quiz with invalid source', + source: 'invalid_source_type', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with null questions returns 400', async () => { + const invalidQuiz = { + title: 'Quiz with null questions', + source: 'manual', + questions: null, + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with question missing options returns 400', async () => { + const invalidQuiz = { + title: 'Quiz missing options', + source: 'manual', + questions: [ + { + text: 'Question without options?', + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('PUT /api/quizzes/:id with invalid data returns 400', async () => { + const validQuiz = { + title: 'Valid Quiz for Update Test', + source: 'manual', + questions: [ + { + text: 'Valid question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', validQuiz, 201); + const quizId = (data as { id: string }).id; + + const invalidUpdate = { + title: '', + questions: [], + }; + + await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + console.log('\nPhase 6 - Malformed Request Tests:'); + + await test('POST /api/quizzes with malformed JSON returns 400', async () => { + const res = await fetch(`${API_URL}/api/quizzes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TOKEN}`, + }, + body: '{ invalid json }', + }); + if (res.status !== 400) throw new Error(`Expected 400, got ${res.status}`); + }); + + await test('GET /api/quizzes/:id with very long ID returns 404', async () => { + const longId = 'a'.repeat(1000); + await request('GET', `/api/quizzes/${longId}`, undefined, 404); + }); + + await test('DELETE /api/quizzes/:id with SQL injection attempt returns 404', async () => { + const maliciousId = "'; DROP TABLE quizzes; --"; + await request('DELETE', `/api/quizzes/${encodeURIComponent(maliciousId)}`, undefined, 404); + }); + + console.log('\nPhase 6 - Content Type Tests:'); + + await test('POST /api/quizzes without Content-Type still works with JSON body', async () => { + const quiz = { + title: 'No Content-Type Quiz', + source: 'manual', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const res = await fetch(`${API_URL}/api/quizzes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(quiz), + }); + + if (res.status !== 201) throw new Error(`Expected 201, got ${res.status}`); + const data = await res.json(); + await request('DELETE', `/api/quizzes/${data.id}`, undefined, 204); + }); + + console.log('\nPhase 6 - Boundary Tests:'); + + await test('POST /api/quizzes with many questions succeeds', async () => { + const manyQuestions = Array.from({ length: 50 }, (_, i) => ({ + text: `Question ${i + 1}?`, + timeLimit: 20, + options: [ + { text: `A${i}`, isCorrect: true, shape: 'triangle', color: 'red' }, + { text: `B${i}`, isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + })); + + const quiz = { + title: '50 Question Quiz', + source: 'manual', + questions: manyQuestions, + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + const savedQuiz = getResult as { questions: unknown[] }; + if (savedQuiz.questions.length !== 50) { + throw new Error(`Expected 50 questions, got ${savedQuiz.questions.length}`); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with many options per question succeeds', async () => { + const manyOptions = Array.from({ length: 10 }, (_, i) => ({ + text: `Option ${i + 1}`, + isCorrect: i === 0, + shape: 'triangle', + color: 'red', + })); + + const quiz = { + title: '10 Options Quiz', + source: 'manual', + questions: [ + { + text: 'Question with 10 options?', + options: manyOptions, + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + const savedQuiz = getResult as { questions: { options: unknown[] }[] }; + if (savedQuiz.questions[0].options.length !== 10) { + throw new Error(`Expected 10 options, got ${savedQuiz.questions[0].options.length}`); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with unicode characters in all fields', async () => { + const unicodeQuiz = { + title: 'Emoji Quiz title test', + source: 'manual', + aiTopic: 'Japanese test', + questions: [ + { + text: 'What is this character?', + timeLimit: 30, + options: [ + { text: 'Option A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Because Emoji test!' }, + { text: 'Chinese characters', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', unicodeQuiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + const savedQuiz = getResult as { title: string; questions: { text: string; options: { text: string }[] }[] }; + + if (savedQuiz.title !== 'Emoji Quiz title test') throw new Error('Unicode title not preserved'); + if (savedQuiz.questions[0].text !== 'What is this character?') throw new Error('Unicode question not preserved'); + if (savedQuiz.questions[0].options[1].text !== 'Chinese characters') throw new Error('Unicode option not preserved'); + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + console.log('\nPhase 6 - Duplicate/Idempotency Tests:'); + + await test('POST /api/quizzes with same data creates separate quizzes', async () => { + const quiz = { + title: 'Duplicate Test Quiz', + source: 'manual', + questions: [ + { + text: 'Same question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data: data1 } = await request('POST', '/api/quizzes', quiz, 201); + const { data: data2 } = await request('POST', '/api/quizzes', quiz, 201); + + const id1 = (data1 as { id: string }).id; + const id2 = (data2 as { id: string }).id; + + if (id1 === id2) throw new Error('Duplicate POST should create separate quizzes with unique IDs'); + + await request('DELETE', `/api/quizzes/${id1}`, undefined, 204); + await request('DELETE', `/api/quizzes/${id2}`, undefined, 204); + }); + + await test('DELETE /api/quizzes/:id twice returns 404 on second call', async () => { + const quiz = { + title: 'Double Delete Quiz', + source: 'manual', + questions: [ + { + text: 'Q?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 404); + }); + + console.log('\n=== Results ==='); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log(`Passed: ${passed}/${results.length}`); + console.log(`Failed: ${failed}/${results.length}`); + + if (failed > 0) { + console.log('\nFailed tests:'); + results + .filter((r) => !r.passed) + .forEach((r) => console.log(` - ${r.name}: ${r.error}`)); + process.exit(1); + } + + console.log('\nAll tests passed!'); +} + +runTests().catch((err) => { + console.error('Test runner error:', err); + process.exit(1); +}); diff --git a/server/tests/get-token.ts b/server/tests/get-token.ts new file mode 100644 index 0000000..ecb231a --- /dev/null +++ b/server/tests/get-token.ts @@ -0,0 +1,122 @@ +const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; +const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; +const CLIENT_SECRET = process.env.CLIENT_SECRET || ''; +const USERNAME = process.env.TEST_USERNAME || ''; +const PASSWORD = process.env.TEST_PASSWORD || ''; + +async function getTokenWithClientSecret(): Promise { + if (!CLIENT_SECRET) throw new Error('CLIENT_SECRET not set'); + + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + scope: 'openid profile email', + }); + + console.log(` Trying client_credentials with client_secret...`); + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`${response.status} - ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + +async function getTokenWithServiceAccount(): Promise { + if (!USERNAME || !PASSWORD) throw new Error('USERNAME and PASSWORD not set'); + + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scope: 'openid profile email', + }); + + console.log(` Trying client_credentials with username/password...`); + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`${response.status} - ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + +async function main() { + console.log('Kaboot API Token Generator'); + console.log('==========================\n'); + console.log(`Authentik URL: ${AUTHENTIK_URL}`); + console.log(`Client ID: ${CLIENT_ID}`); + console.log(''); + + let token: string | null = null; + + if (CLIENT_SECRET) { + try { + token = await getTokenWithClientSecret(); + console.log(' ✓ Success!\n'); + } catch (error) { + console.log(` ✗ Failed: ${error instanceof Error ? error.message : error}\n`); + } + } + + if (!token && USERNAME && PASSWORD) { + try { + token = await getTokenWithServiceAccount(); + console.log(' ✓ Success!\n'); + } catch (error) { + console.log(` ✗ Failed: ${error instanceof Error ? error.message : error}\n`); + } + } + + if (token) { + console.log('=== ACCESS TOKEN ==='); + console.log(token); + console.log('\n=== FOR .env.test ==='); + console.log(`TEST_TOKEN=${token}`); + return; + } + + console.log('=== SETUP INSTRUCTIONS ===\n'); + console.log('Method 1: Client Secret (Recommended for testing)\n'); + console.log(' 1. Go to Authentik Admin: http://localhost:9000/if/admin/'); + console.log(' 2. Navigate to: Applications → Providers → Kaboot OAuth2'); + console.log(' 3. Change "Client type" from "Public" to "Confidential"'); + console.log(' 4. Copy the "Client Secret" value'); + console.log(' 5. Add to server/.env.test:'); + console.log(' CLIENT_SECRET=\n'); + + console.log('Method 2: Service Account + App Password\n'); + console.log(' 1. Go to Authentik Admin: http://localhost:9000/if/admin/'); + console.log(' 2. Navigate to: Directory → Users'); + console.log(' 3. Click "Create Service Account"'); + console.log(' 4. Name it (e.g., "kaboot-test")'); + console.log(' 5. After creation, click on the user → "App passwords" tab'); + console.log(' 6. Create a new app password, copy the token'); + console.log(' 7. Bind the service account to Kaboot app:'); + console.log(' Applications → Kaboot → Policy/Group/User Bindings → Bind existing user'); + console.log(' 8. Add to server/.env.test:'); + console.log(' TEST_USERNAME='); + console.log(' TEST_PASSWORD=\n'); + + process.exit(1); +} + +main(); diff --git a/server/tests/run-tests.ts b/server/tests/run-tests.ts new file mode 100644 index 0000000..5e33360 --- /dev/null +++ b/server/tests/run-tests.ts @@ -0,0 +1,83 @@ +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; +const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; +const USERNAME = process.env.TEST_USERNAME || ''; +const PASSWORD = process.env.TEST_PASSWORD || ''; + +async function getToken(): Promise { + if (!USERNAME || !PASSWORD) { + throw new Error( + 'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' + + 'See tests/README.md for setup instructions.' + ); + } + + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scope: 'openid profile email', + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get token: ${response.status} - ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + +async function runTests(token: string): Promise { + return new Promise((resolve) => { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const testFile = join(__dirname, 'api.test.ts'); + + const child = spawn(process.execPath, [ + '--import', 'tsx', + testFile + ], { + env: { + ...process.env, + TEST_TOKEN: token, + }, + stdio: 'inherit', + }); + + child.on('close', (code) => { + resolve(code ?? 1); + }); + }); +} + +async function main() { + console.log('Kaboot API Test Runner'); + console.log('======================\n'); + + console.log('Obtaining access token from Authentik...'); + let token: string; + try { + token = await getToken(); + console.log(' Token obtained successfully.\n'); + } catch (error) { + console.error(` Failed: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + + console.log('Running API tests...\n'); + const exitCode = await runTests(token); + process.exit(exitCode); +} + +main(); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..bbd4ab1 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/config/oidc.ts b/src/config/oidc.ts new file mode 100644 index 0000000..ba3ac5a --- /dev/null +++ b/src/config/oidc.ts @@ -0,0 +1,19 @@ +import { WebStorageStateStore } from 'oidc-client-ts'; + +const AUTHENTIK_URL = import.meta.env.VITE_AUTHENTIK_URL || 'http://localhost:9000'; +const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID || 'kaboot-spa'; +const APP_SLUG = import.meta.env.VITE_OIDC_APP_SLUG || 'kaboot'; + +export const oidcConfig = { + authority: `${AUTHENTIK_URL}/application/o/${APP_SLUG}/`, + client_id: CLIENT_ID, + redirect_uri: `${window.location.origin}/callback`, + post_logout_redirect_uri: window.location.origin, + response_type: 'code', + scope: 'openid profile email offline_access', + automaticSilentRenew: true, + silentRequestTimeoutInSeconds: 10, + loadUserInfo: true, + userStore: new WebStorageStateStore({ store: window.localStorage }), + monitorSession: false, +}; diff --git a/types.ts b/types.ts index ea2601a..6fc1adc 100644 --- a/types.ts +++ b/types.ts @@ -31,6 +31,26 @@ export interface Quiz { questions: Question[]; } +export type QuizSource = 'manual' | 'ai_generated'; + +export interface SavedQuiz extends Quiz { + id: string; + source: QuizSource; + aiTopic?: string; + createdAt: string; + updatedAt: string; +} + +export interface QuizListItem { + id: string; + title: string; + source: QuizSource; + aiTopic?: string; + questionCount: number; + createdAt: string; + updatedAt: string; +} + export interface Player { id: string; name: string; diff --git a/vite.config.ts b/vite.config.ts index ee5fb8d..8738b76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); return { server: { - port: 3000, + port: 5173, host: '0.0.0.0', }, plugins: [react()],