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.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)
- 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 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:
- 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'
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
-
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.