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.mdauf ✅ 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.mdaktualisieren 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) => ({...})
- Important: Use
- 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_idfield
Data Flow
- Product Sync: NAV ERP � Push to
/api/erp/products� PostgreSQL - Order Flow: User checkout � PayPal payment � Order in DB � X-API � NAV ERP
- 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/orderendpoint- 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 viaexperimenta_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
- Includes billing address fields:
products- Products synced from NAV ERPcarts/cart_items- Shopping cartorders/order_items- Orders and line itemsroles- Role definitions (private, educator, company)- Primary Key:
code(enum: 'private' | 'educator' | 'company') - No separate UUID id! - Benefit: Direct readability in junction tables
- Primary Key:
user_roles- Many-to-Many User ↔ Roles (with approval workflow prepared for Phase 2/3)- Foreign Key:
roleCodereferencesroles.code
- Foreign Key:
product_role_visibility- Many-to-Many Product ↔ Roles (controls product visibility)- Foreign Key:
roleCodereferencesroles.code
- Foreign Key:
Role-based Visibility (MVP):
- Products are ONLY visible if they have
product_role_visibilityentries - 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:
- Starte den Dev-Server (
pnpm dev) - Nutze Playwright MCP Tools um die Anwendung zu navigieren und zu testen
- Validiere visuelle Darstellung und Funktionalität
- Dokumentiere gefundene Issues oder bestätige erfolgreiche Implementierung
Important Constraints
-
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.
-
Mobile-First: All UI must be optimized for mobile devices first, then scale up to desktop.
-
Corporate Design: Use experimenta style guide for colors and fonts (configured in Tailwind).
-
NAV ERP Integration: Products are pushed to us (we don't pull). Implement robust endpoint with API key authentication and validation.
-
No Roles in MVP: Don't implement role selection or role-based features yet. All users are implicitly "Privatperson" (private individual).
-
TypeScript Strict Mode: Use strict TypeScript everywhere. All schemas defined with Zod.
-
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 (
.envis 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
joselibrary - 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 →
privaterole automatically assigned - ✅ Existing users without roles →
privaterole 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
-
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)
-
State Parameter:
- Generate random state for CSRF protection
- Validate state on callback
- Store in temporary cookie (5min TTL)
-
Encrypted Sessions:
- Use
nuxt-auth-utilsfor session management - Sessions encrypted with AES-256-GCM
- HTTP-only, Secure, SameSite=Lax cookies
- 30-day expiration (configurable)
- Use
-
Rate Limiting:
- Login: 5 attempts / 15min per IP
- Register: 3 attempts / hour per IP
- Implement in middleware, not individual endpoints
-
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- Complete implementation guidedocs/ARCHITECTURE.md#3.6- Architecture diagrams and flows
Documentation
- PRD: See
docs/PRD.mdfor complete requirements - Tech Stack: See
docs/TECH_STACK.mdfor technology decisions - Architecture: See
docs/ARCHITECTURE.mdfor system design and data flows - experimenta Info: See
docs/EXPERIMENTA_INFO.mdfor 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"