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.
 
 
 

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

Use Drizzle ORM for all database operations. All tables use UUID primary keys.

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

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'

Authentication Patterns

See docs/CIDAAS_INTEGRATION.md for complete Cidaas OAuth2 implementation guide.

OAuth2 Login Flow Pattern

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