Phase 2 + 3 complete

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 15:20:46 -07:00
commit 6d24f3c112
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
25 changed files with 3275 additions and 98 deletions

4
server/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
*.log
.env*

View file

@ -8,7 +8,9 @@ COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
RUN npm run build && cp src/db/schema.sql dist/db/
RUN mkdir -p /data
EXPOSE 3001

2241
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,9 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "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",

View file

@ -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}`);

44
server/src/db/schema.sql Normal file
View file

@ -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);

View file

@ -1,16 +1,38 @@
import express from 'express';
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' }));
app.use(express.json());
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
}));
app.use(express.json({ limit: '10mb' }));
app.get('/health', (_req, res) => {
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
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);
});

View file

@ -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();
}
);
}

View file

@ -0,0 +1,225 @@
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<string, unknown> | 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<string, unknown>[];
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<string, unknown>[];
return {
...q,
options: options.map((o) => ({
...o,
isCorrect: Boolean(o.isCorrect),
})),
};
});
res.json({
...quiz,
questions: questionsWithOptions,
});
});
router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, questions } = body;
if (!title || !source || !questions?.length) {
res.status(400).json({ error: 'Missing required fields: title, source, questions' });
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;
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;

View file

@ -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<string, unknown> | 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;

68
server/tests/README.md Normal file
View file

@ -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=<service-account-username> \
TEST_PASSWORD=<generated-token> \
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 |

191
server/tests/api.test.ts Normal file
View file

@ -0,0 +1,191 @@
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="<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<void>) {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>[];
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<string, unknown>;
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('\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);
});

79
server/tests/get-token.ts Normal file
View file

@ -0,0 +1,79 @@
const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000';
const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa';
const APP_SLUG = process.env.APP_SLUG || 'kaboot';
const USERNAME = process.env.TEST_USERNAME || 'kaboottest';
const PASSWORD = process.env.TEST_PASSWORD || 'kaboottest';
async function getTokenViaPasswordGrant(): Promise<string> {
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(`Token URL: ${tokenUrl}`);
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(`Password grant failed: ${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(`Username: ${USERNAME}`);
console.log('');
try {
console.log('Attempting password/client_credentials grant...');
const token = await getTokenViaPasswordGrant();
console.log('\n✓ Token obtained successfully!\n');
console.log('=== ACCESS TOKEN ===');
console.log(token);
console.log('\n=== EXPORT COMMAND ===');
console.log(`export TEST_TOKEN="${token}"`);
return;
} catch (error) {
console.log(`${error instanceof Error ? error.message : error}\n`);
}
console.log('=== MANUAL TOKEN SETUP ===\n');
console.log('Option 1: Create a Service Account in Authentik');
console.log(' 1. Go to: Admin > Directory > Users');
console.log(' 2. Click "Create Service Account"');
console.log(' 3. Give it a name (e.g., "kaboot-test")');
console.log(' 4. Copy the username and token generated');
console.log(' 5. Run: TEST_USERNAME=<username> TEST_PASSWORD=<token> npm run test:get-token\n');
console.log('Option 2: Get token from browser');
console.log(' 1. Log into Kaboot frontend with Authentik');
console.log(' 2. Open browser DevTools > Application > Local Storage');
console.log(' 3. Find the oidc.user entry');
console.log(' 4. Copy the access_token value');
console.log(' 5. Run: export TEST_TOKEN="<token>"\n');
console.log('Option 3: Use Authentik API directly');
console.log(' 1. Go to: Admin > Directory > Tokens & App passwords');
console.log(' 2. Create a new token for your user');
console.log(' 3. Use that token for API testing\n');
process.exit(1);
}
main();