# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**my.experimenta.science** is an e-commerce platform for the experimenta Science Center. It allows visitors to purchase Makerspace annual passes (MVP) and extends their existing web shop.

**Domain:** `my.experimenta.science`
**Status:** In Planning / Initial Setup

---

## 📋 Task Management & Progress Tracking

**WICHTIG:** Dieses Projekt nutzt ein strukturiertes Task-System im `tasks/` Ordner.

### Workflow für alle Implementierungen:

**1. Vor Start einer Arbeitssession:**

- Lies `tasks/00-PROGRESS.md` → Identifiziere aktuelle Phase
- Öffne die relevante Phase-Datei (z.B. `tasks/03-authentication.md`)
- Prüfe Dependencies der Phase (sind alle abhängigen Phasen abgeschlossen?)

**2. Während der Implementierung:**

- Arbeite Tasks sequenziell ab (von oben nach unten in der Phase-Datei)
- Markiere abgeschlossene Tasks: `- [ ]` → `- [x]`
- Aktualisiere Progress-Zeile in Phase-Datei: `Progress: X/Y tasks (Z%)`
- Dokumentiere wichtige Entscheidungen im Notes-Bereich
- Bei Blocker: Status auf 🚫 setzen, Grund dokumentieren

**3. Nach jedem abgeschlossenen Task:**

- Update Phase-Datei (Task checkbox + Progress-Zeile)
- Update `tasks/00-PROGRESS.md`:
  - Aktualisiere Progress in "Quick Status" Tabelle
  - Aktualisiere "Current Work" Section (welche Phase, welcher Task, nächste Schritte)
  - Falls Phase abgeschlossen: Status auf ✅, Started/Completed Datum eintragen

**4. Phase-Abschluss:**

- Prüfe: Alle Acceptance Criteria erfüllt?
- Setze Status in Phase-Datei auf ✅ Done
- Setze Status in `00-PROGRESS.md` auf ✅ Done
- Trage "Completed" Datum ein
- **⚠️ Frage den Benutzer explizit, ob mit der nächsten Phase fortgefahren werden soll**
- **Erst nach Bestätigung:** Identifiziere nächste Phase und starte mit Schritt 1

### Niemals vergessen:

- ⚠️ **Immer** `00-PROGRESS.md` aktualisieren nach jedem abgeschlossenen Task
- ⚠️ **Immer** Phase-Datei und PROGRESS.md synchron halten
- ⚠️ **Immer** bei Unterbrechung "Current Work" in PROGRESS.md dokumentieren
- ⚠️ **Niemals** mehrere Phasen gleichzeitig bearbeiten (sequenziell arbeiten)

**Siehe:** [`tasks/README.md`](./tasks/README.md) für vollständige Dokumentation des Task-Systems.

---

## Code Style Requirements

- **All code comments MUST be written in English**
- User-facing content (UI text, emails) should be in German
- **User communication MUST use informal "Du" form** (not formal "Sie" form)
  - Examples: "Dein Konto", "Melde dich an", "Bestätige deine E-Mail"
  - This applies to all UI text, form labels, buttons, messages, and emails
- Documentation should be in German (PRD, README)

## Tech Stack

- **Framework:** Nuxt 4 (Vue 3 Composition API + TypeScript)
- **UI Library:** shadcn-nuxt (<https://nuxt.com/modules/shadcn>)
- **Styling:** Tailwind CSS v4
- **Database:** PostgreSQL 16+
- **ORM:** Drizzle ORM (TypeScript-first, performant)
  - **Important:** Use `(table) => [...]` for indexes/constraints, NOT `(table) => ({...})`
- **Queue System:** BullMQ (MIT License) - Async job processing
- **In-Memory Store:** Redis 7 - Queue storage, sessions, caching
- **Auth:** Cidaas (OIDC/OAuth2) - external platform
- **Payment:** PayPal (MVP), more providers later
- **i18n:** @nuxtjs/i18n - German (default) + English
- **Validation:** Zod + VeeValidate
- **State Management:** Pinia (minimal use, prefer composables)
- **Testing:** Vitest (unit/integration), Playwright (E2E)
- **Deployment:** Docker on Hetzner Proxmox
- **CI/CD:** GitLab

## Architecture Principles

### Authentication & User Management

**Critical:** Cidaas is **only** for authentication (login/registration). User profiles and roles are stored in the local PostgreSQL database.

- Cidaas provides: OAuth2 authentication, user identity
- Local DB stores: User profile, roles, preferences, purchase history
- Custom UI for login/registration (not Cidaas hosted pages)
- Link users via `experimenta_id` field

### Data Flow

1. **Product Sync:** NAV ERP � Push to `/api/erp/products` � PostgreSQL
2. **Order Flow:** User checkout � PayPal payment � Order in DB � **X-API** � NAV ERP
3. **Auth Flow:** User login � Cidaas OAuth � Create/update local user profile � Session

### Key Integrations

- **NAV ERP:** Microsoft Dynamics NAV - Push products/stock to our API
- **X-API:** Order submission to NAV ERP via `/shopware/order` endpoint
  - Environments: x-api-{dev|stage|live}.experimenta.science
  - **Authentication:** HTTP Basic Auth (username + password)
  - Credentials stored in environment variables (never hardcoded)
  - Converts our JSON orders to SOAP for NAV ERP
  - Critical: Prices must be in cents (Integer), dates in ISO 8601 UTC
- **Cidaas:** Auth platform by Widas (company requirement)

## MVP Scope (Phase 1)

**In Scope:**

- User registration and login (email-based via Cidaas)
- Display Makerspace annual passes ("Makerspace-Jahreskarten")
- Shopping cart functionality
- Checkout process
- PayPal payment integration
- NAV ERP product sync (receive products via push)

**Out of Scope (Post-MVP):**

- User roles (Educator, Company) - MVP only supports "Privatperson" implicitly
- Educator annual passes ("Pädagogische Jahreskarten")
- Approval workflows
- Experimenta tickets + Science Dome seat reservations
- Lab courses for schools
- Multi-payment providers

## Project Structure (Once Initialized)

```
/
├── app/
│   ├── components/          # Vue components (Nuxt 4 structure)
│   │   └── ui/             # shadcn-nuxt components
│   ├── layouts/            # Nuxt layouts
│   ├── lib/                # Client-side utilities
│   │   └── utils.ts        # Shared utilities
│   └── pages/              # File-based routing (Nuxt 4)
├── server/
│   ├── api/                # API routes (Nitro)
│   │   ├── auth/          # Cidaas OAuth handlers
│   │   ├── products/      # Product endpoints
│   │   ├── cart/          # Shopping cart
│   │   ├── orders/        # Order management
│   │   ├── payment/       # PayPal integration
│   │   └── erp/           # NAV ERP endpoints (API key protected)
│   ├── database/
│   │   ├── schema.ts      # Drizzle schema definitions
│   │   └── migrations/    # DB migrations
│   └── utils/             # Server-side utilities
├── components/             # Legacy components (to be migrated to app/)
├── composables/            # Vue composables
├── pages/                  # Legacy pages (to be migrated to app/)
├── docs/                   # Documentation
│   ├── PRD.md             # Product Requirements Document
│   ├── TECH_STACK.md      # Tech decisions & rationale
│   └── ARCHITECTURE.md    # System architecture
└── docker-compose.yml     # Docker setup
```

## Database Schema

See `docs/ARCHITECTURE.md` for full schema. Key tables:

- `users` - User profiles (linked to Cidaas via `experimenta_id`)
  - **Includes billing address fields:** `salutation`, `date_of_birth`, `street`, `post_code`, `city`, `country_code`
  - Address fields are optional, filled during checkout or profile edit
  - Pre-fills checkout form for returning customers
- `products` - Products synced from NAV ERP
- `carts` / `cart_items` - Shopping cart
- `orders` / `order_items` - Orders and line items
- **`roles`** - Role definitions (private, educator, company)
  - **Primary Key:** `code` (enum: 'private' | 'educator' | 'company') - No separate UUID id!
  - Benefit: Direct readability in junction tables
- **`user_roles`** - Many-to-Many User ↔ Roles (with approval workflow prepared for Phase 2/3)
  - Foreign Key: `roleCode` references `roles.code`
- **`product_role_visibility`** - Many-to-Many Product ↔ Roles (controls product visibility)
  - Foreign Key: `roleCode` references `roles.code`

**Role-based Visibility (MVP):**
- Products are ONLY visible if they have `product_role_visibility` entries
- Users ONLY see products matching their approved roles in `user_roles`
- Opt-in visibility: No role assignment = invisible to everyone
- **Auto-assignment:** New users automatically receive `'private'` role on first login

Use Drizzle ORM for all database operations. Most tables use UUID primary keys (except `roles` which uses enum code as PK).

## Development Commands (Once Set Up)

```bash
# Install dependencies
pnpm install

# Development server
pnpm dev

# Build for production
pnpm build

# Run production build locally
pnpm preview

# Database migrations
pnpm db:generate    # Generate migrations from schema changes
pnpm db:migrate     # Apply migrations to database
pnpm db:studio      # Open Drizzle Studio (DB GUI)

# Testing
pnpm test           # Run unit tests (Vitest)
pnpm test:e2e       # Run E2E tests (Playwright)
pnpm test:coverage  # Generate coverage report

# Code quality
pnpm lint           # ESLint
pnpm format         # Prettier
pnpm typecheck      # TypeScript type checking
```

## Development Tools & Testing

### Test Credentials (Cidaas Staging)

**📖 Complete test credentials and testing guide:** [`docs/TESTING.md`](./docs/TESTING.md)

**Important:**
- Test credentials are documented in [`docs/TESTING.md`](./docs/TESTING.md)
- ⚠️ **Only for staging environment** - NEVER use in production
- Used by Playwright E2E tests and Vitest integration tests

### Playwright MCP Server

Ein Playwright MCP Server ist installiert und verfügbar für:

- **UI/UX Überprüfung:** Visuelle Validierung von Komponenten und Layouts
- **Funktionale Tests:** Interaktive Tests während der Entwicklung
- **Responsive Design Checks:** Überprüfung der Mobile-First Implementierung
- **User Flow Testing:** Validierung kompletter Benutzer-Workflows (z.B. Checkout-Prozess)

**Wann nutzen:**

- Nach Implementierung oder Änderung von UI-Komponenten
- Bei Layout-Anpassungen (besonders mobile vs. desktop)
- Zum Testen von Formularen und Interaktionen
- Zur Validierung des gesamten Checkout-Flows
- Bei Integration von shadcn-nuxt Komponenten

**Workflow:**

1. Starte den Dev-Server (`pnpm dev`)
2. Nutze Playwright MCP Tools um die Anwendung zu navigieren und zu testen
3. Validiere visuelle Darstellung und Funktionalität
4. Dokumentiere gefundene Issues oder bestätige erfolgreiche Implementierung

## Important Constraints

1. **Cidaas Custom UI:** We must implement custom login/registration forms in our app (not hosted by Cidaas). Use OIDC/OAuth2 flow with Cidaas as identity provider.

2. **Mobile-First:** All UI must be optimized for mobile devices first, then scale up to desktop.

3. **Corporate Design:** Use experimenta style guide for colors and fonts (configured in Tailwind).

4. **NAV ERP Integration:** Products are pushed to us (we don't pull). Implement robust endpoint with API key authentication and validation.

5. **No Roles in MVP:** Don't implement role selection or role-based features yet. All users are implicitly "Privatperson" (private individual).

6. **TypeScript Strict Mode:** Use strict TypeScript everywhere. All schemas defined with Zod.

7. **Bilingual Support:** App must support German (default) and English. Use @nuxtjs/i18n for all user-facing text. Routes: `/produkte` (de), `/en/products` (en).

## Security Considerations

- Never commit secrets (`.env` is gitignored)
- All API endpoints must validate input (use Zod schemas)
- ERP endpoints must be protected with API key authentication
- Use session-based auth (HTTP-only cookies)
- Validate JWT tokens from Cidaas using `jose` library
- Implement rate limiting on public endpoints

## Common Patterns

### API Route Example

```typescript
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
  // Validate path params with Zod
  const { id } = await getValidatedRouterParams(
    event,
    z.object({
      id: z.string().uuid(),
    }).parse
  )

  // Query with Drizzle
  const product = await db.query.products.findFirst({
    where: eq(products.id, id),
  })

  if (!product) {
    throw createError({
      statusCode: 404,
      message: 'Product not found',
    })
  }

  return product
})
```

### Protected Route Example

```typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to, from) => {
  const { user } = await useAuth()

  if (!user.value) {
    return navigateTo('/login')
  }
})
```

### Checkout with Saved Address Pattern

```typescript
// pages/checkout.vue
<script setup lang="ts">
const { user } = await useAuth()
const { saveAddress } = useUserProfile()

// Pre-fill form if user has saved address
const form = reactive({
  salutation: user.value?.salutation || '',
  dateOfBirth: user.value?.dateOfBirth || '',
  street: user.value?.street || '',
  postCode: user.value?.postCode || '',
  city: user.value?.city || '',
  countryCode: user.value?.countryCode || 'DE',
  // Pre-checked if user doesn't have address yet
  saveForFuture: !user.value?.street
})

async function handleCheckout() {
  // Validate form
  const validated = await checkoutSchema.parseAsync(form)

  // Save address to profile if checkbox is checked
  if (form.saveForFuture) {
    await saveAddress({
      salutation: validated.salutation,
      dateOfBirth: validated.dateOfBirth,
      street: validated.street,
      postCode: validated.postCode,
      city: validated.city,
      countryCode: validated.countryCode,
    })
  }

  // Continue with payment
  await processPayment(validated)
}
</script>

<template>
  <form @submit.prevent="handleCheckout">
    <h2>{{ $t('checkout.billingAddress') }}</h2>

    <!-- Address form fields -->
    <Input v-model="form.street" :label="$t('checkout.street')" required />
    <Input v-model="form.postCode" :label="$t('checkout.postCode')" required />
    <Input v-model="form.city" :label="$t('checkout.city')" required />

    <!-- Save address checkbox -->
    <Checkbox
      v-model="form.saveForFuture"
      :label="$t('checkout.saveAddress')"
    />

    <Button type="submit">{{ $t('checkout.continue') }}</Button>
  </form>
</template>
```

### X-API Order Transformation Pattern

```typescript
// server/utils/xapi.ts

// Transform order from DB to X-API format
export function transformOrderToXAPI(order: Order, user: User) {
  return {
    shopPOSOrder: {
      documentType: 'Order',
      externalDocumentNo: order.orderNumber,
      salesChannel: 'Shop',
      shoppingCartCompletion: order.createdAt.toISOString(),
      visitType: 'Private',
      amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR -> Cents!
      language: 'DEU',

      salesLine: order.items.map((item, index) => ({
        type: 'Item',
        lineNo: String((index + 1) * 10000), // 10000, 20000, 30000...
        no: item.product.navProductId,
        quantity: item.quantity,
        unitPrice: Math.round(item.priceSnapshot * 100), // EUR -> Cents!
        vatPct: 7,
        visitorCategory: '120', // Annual pass holders
        ticketCode: generateUniqueTicketCode(),

        // Required for annual passes!
        annualPass: {
          salutationCode: mapSalutation(user.salutation), // 'HERR', 'FRAU', 'K_ANGABE'
          firstName: user.firstName,
          lastName: user.lastName,
          streetAndHouseNumber: user.address.street,
          postCode: user.address.postCode,
          city: user.address.city,
          countryCode: user.address.countryCode || 'DE',
          dateOfBirth: user.dateOfBirth,
          eMail: user.email,
          validFrom: new Date().toISOString().split('T')[0], // YYYY-MM-DD
        },
      })),

      payment: [
        {
          paymentEntryNo: 10000,
          amount: Math.round(order.totalAmount * 100), // EUR -> Cents!
          paymentType: 'Shop PayPal',
          createdOn: order.paymentCompletedAt.toISOString(),
          reference: order.paymentId,
          paymentId: order.paymentId,
        },
      ],

      personContact: {
        experimentaAccountID: user.experimentaId, // Our experimenta_id!
        eMail: user.email,
        salutationCode: mapSalutation(user.salutation),
        firstName: user.firstName,
        lastName: user.lastName,
        streetAndHouseNumber: order.billingAddress.street,
        postCode: order.billingAddress.postCode,
        city: order.billingAddress.city,
        countryCode: order.billingAddress.countryCode || 'DE',
        phoneNumber: user.phone || '',
        guest: false,
      },
    },
  }
}

// Submit to X-API with retry logic and Basic Auth
export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
  const config = useRuntimeConfig()
  const maxRetries = 3
  const retryDelays = [1000, 3000, 9000] // Exponential backoff

  // Prepare Basic Auth header
  const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64')

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Basic ${authString}`,
        },
        body: JSON.stringify(payload),
      })

      if (!response.ok) {
        const errorText = await response.text()
        throw new Error(`X-API error ${response.status}: ${errorText}`)
      }

      return await response.json()
    } catch (error) {
      if (attempt === maxRetries - 1) throw error
      await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]))
    }
  }
}
```

**Critical transformations:**

- Prices: EUR (Decimal) → Cents (Integer) using `Math.round(price * 100)`
- Dates: JavaScript Date → ISO 8601 UTC using `.toISOString()`
- Line numbers: Sequential multiples of 10000
- Salutation: 'male' → 'HERR', 'female' → 'FRAU', other → 'K_ANGABE'

## Role-based Product Visibility Patterns (MVP)

### Auto-Assignment of 'private' Role (MVP)

**Requirement**: All users must have at least one role to see products. New users automatically receive the `private` role on first login.

**Implementation** in `server/api/auth/login.post.ts`:

```typescript
if (!user) {
  // First time login - create user profile
  const [newUser] = await db.insert(users).values({
    experimentaId: cidaasUser.sub,
    email: cidaasUser.email,
    firstName: cidaasUser.given_name || '',
    lastName: cidaasUser.family_name || '',
  }).returning()

  user = newUser

  // Auto-assign 'private' role on first login
  await assignRoleToUser(newUser.id, 'private', {
    adminNotes: 'Auto-assigned on first login',
  })
} else {
  // Update last login timestamp
  await db.update(users)
    .set({ updatedAt: new Date() })
    .where(eq(users.id, user.id))

  // Safety check: If existing user has no roles, assign 'private' role
  const userRoleCodes = await getUserApprovedRoleCodes(user.id)
  if (userRoleCodes.length === 0) {
    await assignRoleToUser(user.id, 'private', {
      adminNotes: 'Auto-assigned for existing user without roles',
    })
  }
}
```

**Key Points:**
- ✅ New users → `private` role automatically assigned
- ✅ Existing users without roles → `private` role assigned (safety check)
- ✅ Status always `approved` (no approval workflow in MVP)
- ✅ Admin notes track auto-assignment source

### Role-based Filtering Pattern

```typescript
// Server-side: GET /api/products - Filter products by user roles
import { getVisibleProductIdsForUser } from '~/server/utils/roles'

export default defineEventHandler(async (event) => {
  const { user } = await getUserSession(event)

  // MVP: Unauthenticated users see NO products (opt-in visibility)
  if (!user) {
    return []
  }

  // Get product IDs visible to this user (based on approved roles)
  const visibleProductIds = await getVisibleProductIdsForUser(user.id)

  if (visibleProductIds.length === 0) {
    return []
  }

  // Fetch products with role-based filtering
  const products = await db.query.products.findMany({
    where: and(
      eq(products.active, true),
      inArray(products.id, visibleProductIds) // Role filter
    )
  })

  return products
})
```

### ERP Category to Role Mapping Pattern

```typescript
// Auto-assign roles when importing products from NAV ERP
import { assignRolesToProductByCategory } from '~/server/utils/roles'

// server/api/erp/products.post.ts
export default defineEventHandler(async (event) => {
  const { navProductId, category, ...productData } = await readBody(event)

  // 1. Create/update product
  const [product] = await db.insert(products)
    .values({ navProductId, category, ...productData })
    .onConflictDoUpdate({ target: products.navProductId, set: productData })
    .returning()

  // 2. Auto-assign roles based on category mapping
  await assignRolesToProductByCategory(product.id, category)

  return product
})
```

**Category Mapping:**
```typescript
// Defined in server/utils/roles.ts
const categoryRoleMapping = {
  'makerspace-annual-pass': ['private', 'educator'],
  'annual-pass': ['private'],
  'educator-annual-pass': ['educator'],
  'company-annual-pass': ['company']
}
```

### Get User Approved Roles Pattern

```typescript
// server/utils/roles.ts - Utility functions
import { getUserApprovedRoles, getUserApprovedRoleCodes } from '~/server/utils/roles'

// Get full role objects
const roles = await getUserApprovedRoles(userId)
// => [{ id: '...', code: 'private', displayName: 'Privatperson', ... }]

// Get just role codes (lightweight)
const roleCodes = await getUserApprovedRoleCodes(userId)
// => ['private', 'educator']
```

### Manual Role Assignment Pattern (MVP)

```typescript
// server/utils/roles.ts - For manual role assignment
import { assignRoleToUser } from '~/server/utils/roles'

// Assign role to user (MVP: always approved)
await assignRoleToUser(userId, 'private')

// With optional metadata (prepared for Phase 2/3)
await assignRoleToUser(userId, 'educator', {
  organizationName: 'Hölderlin-Gymnasium Heilbronn',
  adminNotes: 'Verified via school email domain'
})
```

### Check Product Visibility Pattern

```typescript
// server/utils/roles.ts - Check if specific product is visible
import { isProductVisibleForUser } from '~/server/utils/roles'

const canView = await isProductVisibleForUser(productId, userId)
// => true if user has approved role matching product's role assignments
// => false if product has no role assignments (opt-in!) or user lacks role
```

### JSONB Status History Pattern (Phase 2/3 prepared)

```typescript
// user_roles.statusHistory stores complete audit trail
const historyEntry = {
  status: 'approved',
  organizationName: 'Hölderlin-Gymnasium',
  adminNotes: 'Verified via Lehrerausweis',
  changedAt: new Date().toISOString(),
  changedBy: adminUserId // null for auto-assignments
}

await db.update(userRoles)
  .set({
    status: 'approved',
    statusHistory: sql`${userRoles.statusHistory} || ${JSON.stringify(historyEntry)}::jsonb`
  })
  .where(eq(userRoles.id, userRoleId))
```

**Important Visibility Rules (MVP):**
- Unauthenticated users → See NO products
- User without approved roles → See NO products
- Product without role assignments → Visible to NO ONE (opt-in)
- Product with role assignments → Visible only to users with matching approved role

## Authentication Patterns

See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.

### Current Implementation: Password Grant Flow (MVP)

**Important:** The current implementation uses the **Resource Owner Password Credentials Grant** (OAuth2 Password Flow) instead of the Authorization Code Flow with PKCE.

**Why Password Grant for MVP:**
- ✅ **Simpler UX:** User stays in our app, no redirects to Cidaas
- ✅ **Faster development:** Less complex flow, fewer endpoints needed
- ✅ **Sufficient for MVP:** Private users logging in with email/password
- ⚠️ **Trade-off:** Client app handles passwords directly (less secure than authorization code flow)
- ⚠️ **Limitation:** Doesn't support SSO/Social logins (requires redirect flow)

**Future Enhancement:** For Phase 2+, we may implement Authorization Code Flow with PKCE to support:
- Social login (Google, Facebook, Apple)
- Single Sign-On (SSO) for organizations
- Better security (app never sees password)

### Password Grant Login Pattern (Current Implementation)

```typescript
// app/composables/useAuth.ts - Client-side auth composable
export function useAuth() {
  const { loggedIn, user, clear, fetch } = useUserSession()

  async function login(email: string, password: string) {
    // Direct login via Password Grant (no redirect)
    try {
      await $fetch('/api/auth/login', {
        method: 'POST',
        body: { email, password },
      })

      // Refresh session data
      await fetch()

      // Redirect to homepage or intended destination
      navigateTo('/')
    } catch (error) {
      // Handle login error (invalid credentials, etc.)
      throw error
    }
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    await clear()
    navigateTo('/')
  }

  return { user, loggedIn, login, logout }
}
```

**Server-side:**

```typescript
// server/api/auth/login.post.ts - Password Grant login
import { loginWithPassword, fetchUserInfo } from '~/server/utils/cidaas'
import { z } from 'zod'

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
})

export default defineEventHandler(async (event) => {
  // 1. Validate input
  const body = await readBody(event)
  const { email, password } = loginSchema.parse(body)

  try {
    // 2. Authenticate with Cidaas via Password Grant
    const tokens = await loginWithPassword(email, password)

    // 3. Fetch user info from Cidaas
    const cidaasUser = await fetchUserInfo(tokens.access_token)

    // 4. Create/update user in local DB
    const db = useDatabase()
    let user = await db.query.users.findFirst({
      where: eq(users.experimentaId, cidaasUser.sub),
    })

    if (!user) {
      // First time login - create user
      const [newUser] = await db
        .insert(users)
        .values({
          experimentaId: cidaasUser.sub,
          email: cidaasUser.email,
          firstName: cidaasUser.given_name || '',
          lastName: cidaasUser.family_name || '',
        })
        .returning()
      user = newUser
    } else {
      // Update last login timestamp
      await db
        .update(users)
        .set({ updatedAt: new Date() })
        .where(eq(users.id, user.id))
    }

    // 5. Create encrypted session
    await setUserSession(event, {
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
      },
    })

    return { success: true }
  } catch (error) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid credentials',
    })
  }
})
```

### OAuth2 Authorization Code Flow Pattern (Future Enhancement)

```typescript
// composables/useAuth.ts - Client-side auth composable
export function useAuth() {
  const { loggedIn, user, clear, fetch } = useUserSession()

  async function login(email: string) {
    // Initiate OAuth2 Authorization Code Flow with PKCE
    const { redirectUrl } = await $fetch('/api/auth/login', {
      method: 'POST',
      body: { email },
    })

    // Redirect to Cidaas login page
    navigateTo(redirectUrl, { external: true })
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    await clear()
    navigateTo('/')
  }

  return { user, loggedIn, login, logout }
}
```

**Server-side:**

```typescript
// server/api/auth/login.post.ts - Initiate OAuth2 flow
export default defineEventHandler(async (event) => {
  const { email } = await readBody(event)

  // 1. Generate PKCE challenge
  const { verifier, challenge } = await generatePKCE()

  // 2. Store PKCE verifier in cookie (5min TTL)
  setCookie(event, 'pkce_verifier', verifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 300,
  })

  // 3. Generate state (CSRF protection)
  const state = generateState(32)
  setCookie(event, 'oauth_state', state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 300,
  })

  // 4. Build Cidaas authorization URL
  const config = useRuntimeConfig()
  const authUrl = new URL(config.cidaas.authorizeUrl)
  authUrl.searchParams.set('client_id', config.cidaas.clientId)
  authUrl.searchParams.set('redirect_uri', config.cidaas.redirectUri)
  authUrl.searchParams.set('response_type', 'code')
  authUrl.searchParams.set('scope', 'openid profile email')
  authUrl.searchParams.set('state', state)
  authUrl.searchParams.set('code_challenge', challenge)
  authUrl.searchParams.set('code_challenge_method', 'S256')
  authUrl.searchParams.set('login_hint', email)

  return { redirectUrl: authUrl.toString() }
})
```

### OAuth2 Callback Pattern

```typescript
// server/api/auth/callback.get.ts - Handle OAuth2 callback
export default defineEventHandler(async (event) => {
  const { code, state } = getQuery(event)

  // 1. Validate state (CSRF protection)
  const storedState = getCookie(event, 'oauth_state')
  if (!storedState || state !== storedState) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid state parameter',
    })
  }

  // 2. Retrieve PKCE verifier
  const verifier = getCookie(event, 'pkce_verifier')
  if (!verifier) {
    throw createError({
      statusCode: 400,
      statusMessage: 'PKCE verifier not found',
    })
  }

  try {
    // 3. Exchange code for tokens
    const tokens = await exchangeCodeForToken(code as string, verifier)

    // 4. Validate ID token
    const idTokenPayload = await verifyIdToken(tokens.id_token)

    // 5. Fetch user info from Cidaas
    const cidaasUser = await fetchUserInfo(tokens.access_token)

    // 6. Create/update user in local DB
    const db = useDatabase()
    let user = await db.query.users.findFirst({
      where: eq(users.experimentaId, cidaasUser.sub),
    })

    if (!user) {
      // First time login - create user
      const [newUser] = await db
        .insert(users)
        .values({
          experimentaId: cidaasUser.sub,
          email: cidaasUser.email,
          firstName: cidaasUser.given_name,
          lastName: cidaasUser.family_name,
        })
        .returning()
      user = newUser
    } else {
      // Update last login
      await db.update(users).set({ updatedAt: new Date() }).where(eq(users.id, user.id))
    }

    // 7. Create encrypted session
    await setUserSession(event, {
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
      },
    })

    // 8. Clean up temporary cookies
    deleteCookie(event, 'oauth_state')
    deleteCookie(event, 'pkce_verifier')

    // 9. Redirect to homepage
    return sendRedirect(event, '/')
  } catch (error) {
    console.error('OAuth callback error:', error)
    deleteCookie(event, 'oauth_state')
    deleteCookie(event, 'pkce_verifier')
    return sendRedirect(event, '/auth?error=login_failed')
  }
})
```

### Protected Route Middleware Pattern

```typescript
// middleware/auth.ts - Require authentication
export default defineNuxtRouteMiddleware(async (to, from) => {
  const { loggedIn } = useUserSession()

  if (!loggedIn.value) {
    // Store intended destination for post-login redirect
    useCookie('redirect_after_login', {
      maxAge: 600, // 10 minutes
      path: '/',
    }).value = to.fullPath

    return navigateTo('/auth')
  }
})
```

**Usage in pages:**

```vue
<!-- pages/profile.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: 'auth', // Require authentication
})

const { user } = useAuth()
</script>

<template>
  <div>
    <h1>Welcome, {{ user.firstName }}!</h1>
  </div>
</template>
```

### Protected API Endpoint Pattern

```typescript
// server/api/orders/index.get.ts - Protected API route
export default defineEventHandler(async (event) => {
  // Require authentication (throws 401 if not logged in)
  const { user } = await requireUserSession(event)

  // Fetch user's orders
  const db = useDatabase()
  const orders = await db.query.orders.findMany({
    where: eq(orders.userId, user.id),
    orderBy: desc(orders.createdAt),
  })

  return orders
})
```

### User Registration Pattern

```typescript
// server/api/auth/register.post.ts - Register via Cidaas
import { z } from 'zod'

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/),
  firstName: z.string().min(2),
  lastName: z.string().min(2),
})

export default defineEventHandler(async (event) => {
  // Validate input
  const body = await readBody(event)
  const validated = registerSchema.parse(body)

  // Register user via Cidaas API
  const result = await registerUser({
    email: validated.email,
    password: validated.password,
    given_name: validated.firstName,
    family_name: validated.lastName,
    locale: 'de',
  })

  return {
    success: true,
    message: 'Registration successful. Please verify your email.',
  }
})
```

### Session Management Pattern

```typescript
// Server-side: Create session
await setUserSession(event, {
  user: {
    id: user.id,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
  },
  loggedInAt: new Date().toISOString(),
})

// Server-side: Check session
const { user } = await getUserSession(event)
if (!user) {
  throw createError({ statusCode: 401, message: 'Not authenticated' })
}

// Server-side: Require session
const { user } = await requireUserSession(event) // Throws 401 if not logged in

// Server-side: Clear session
await clearUserSession(event)
```

```vue
<!-- Client-side: Use session in components -->
<script setup lang="ts">
const { user, loggedIn } = useUserSession()

// user.value: { id, email, firstName, ... } or null
// loggedIn.value: boolean
</script>

<template>
  <div v-if="loggedIn">
    <p>Welcome {{ user.firstName }}!</p>
  </div>
  <div v-else>
    <NuxtLink to="/auth">Login</NuxtLink>
  </div>
</template>
```

### JWT Validation Pattern

```typescript
// server/utils/jwt.ts - Validate Cidaas ID tokens
import { jwtVerify, createRemoteJWKSet } from 'jose'

const config = useRuntimeConfig()
const JWKS = createRemoteJWKSet(new URL(config.cidaas.jwksUrl))

export async function verifyIdToken(idToken: string) {
  try {
    const { payload } = await jwtVerify(idToken, JWKS, {
      issuer: config.cidaas.issuer,
      audience: config.cidaas.clientId,
    })
    return payload
  } catch (error) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid ID token',
    })
  }
}
```

### Rate Limiting Pattern

```typescript
// server/middleware/rate-limit.ts - Rate limit auth endpoints
interface RateLimitEntry {
  count: number
  resetAt: number
}

const rateLimitStore = new Map<string, RateLimitEntry>()

export default defineEventHandler((event) => {
  const path = event.path
  if (!path.startsWith('/api/auth/')) return

  const ip = getRequestIP(event) || 'unknown'
  const limits = {
    '/api/auth/login': { maxAttempts: 5, windowMs: 15 * 60 * 1000 },
    '/api/auth/register': { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
  }

  const limit = limits[path]
  if (!limit) return

  const key = `${ip}:${path}`
  const now = Date.now()
  const entry = rateLimitStore.get(key)

  if (!entry || entry.resetAt < now) {
    rateLimitStore.set(key, { count: 1, resetAt: now + limit.windowMs })
    return
  }

  entry.count++
  if (entry.count > limit.maxAttempts) {
    throw createError({
      statusCode: 429,
      statusMessage: 'Too many requests',
    })
  }
})
```

### Key Security Practices

1. **PKCE for OAuth2:**
   - Always use PKCE (Proof Key for Code Exchange)
   - Prevents authorization code interception attacks
   - Generate random verifier, compute SHA-256 challenge
   - Store verifier in temporary cookie (5min TTL)

2. **State Parameter:**
   - Generate random state for CSRF protection
   - Validate state on callback
   - Store in temporary cookie (5min TTL)

3. **Encrypted Sessions:**
   - Use `nuxt-auth-utils` for session management
   - Sessions encrypted with AES-256-GCM
   - HTTP-only, Secure, SameSite=Lax cookies
   - 30-day expiration (configurable)

4. **Rate Limiting:**
   - Login: 5 attempts / 15min per IP
   - Register: 3 attempts / hour per IP
   - Implement in middleware, not individual endpoints

5. **Input Validation:**
   - Always validate with Zod schemas
   - Validate on both client and server
   - Never trust client-side validation alone

**Implementation Details:**

For complete implementation with all utilities (PKCE generator, Cidaas API client, full endpoint code), see:

- [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) - Complete implementation guide
- [`docs/ARCHITECTURE.md#3.6`](./docs/ARCHITECTURE.md#36-authentication--authorization-cidaas-oauth2oidc) - Architecture diagrams and flows

## Documentation

- **PRD:** See `docs/PRD.md` for complete requirements
- **Tech Stack:** See `docs/TECH_STACK.md` for technology decisions
- **Architecture:** See `docs/ARCHITECTURE.md` for system design and data flows
- **experimenta Info:** See `docs/EXPERIMENTA_INFO.md` for company information (address, opening hours, legal links, contact details)


## Deployment

- **Platform:** Docker containers on Hetzner Proxmox
- **CI/CD:** GitLab pipelines (build � test � deploy)
- **Environments:** Staging (auto-deploy) + Production (manual approval)
- **Database Backups:** Daily automated backups with 7-day retention

## Future Phases

After MVP, we'll add:

- **Phase 2:** Educator roles + approval workflow + educator annual passes
- **Phase 3:** Experimenta tickets + Science Dome seat reservations
- **Phase 4:** Lab courses for schools

Keep the codebase modular to accommodate these features without major refactoring.

- Always use context7 when I need code generation, setup or configuration steps, or
  library/API documentation. This means you should automatically use the Context7 MCP
  tools to resolve library id and get library docs without me having to explicitly ask.
- URLs should always be created in English and not Germanized. Example: "/checkout" instead of "/kasse"