You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

38 KiB

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

# 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

Important:

  • Test credentials are documented in 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

// 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

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

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

Checkout with Saved Address Pattern

// 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

// 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:

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

// 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

// 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:

// 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

// 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)

// 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

// 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)

// 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 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)

// 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:

// 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)

// 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:

// 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

// 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

// 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:

<!-- 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

// 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

// 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

// 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)
<!-- 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

// 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

// 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:

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.