100 KiB
System-Architektur
my.experimenta.science
Version: 1.0 Datum: 28. Oktober 2025
1. Architektur-Übersicht
Die my.experimenta.science App folgt einer modernen Full-Stack-Architektur mit:
- Nuxt 4 als Full-Stack-Framework (Frontend + Backend)
- PostgreSQL als primäre Datenbank
- Cidaas für Authentifizierung
- Externe Integrationen (NAV ERP, X-API, PayPal)
- Docker für Containerisierung
2. High-Level Architektur
┌─────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Browser │ │ Mobile │ │ Tablet │ │ Desktop │ │
│ │ (PWA) │ │ Safari │ │ iPad │ │ Chrome │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
└────────┼─────────────┼─────────────┼─────────────┼─────────────┘
│ │ │ │
└─────────────┴─────────────┴─────────────┘
│ HTTPS
┌────────────────▼────────────────┐
│ Reverse Proxy (Nginx) │
│ - SSL Termination │
│ - Load Balancing │
│ - Rate Limiting │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────────────────────────┐
│ Nuxt 4 Application │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Frontend │ │ Server API │ │
│ │ - Vue 3 │ │ - Nitro Engine │ │
│ │ - SSR/SSG │ │ - API Routes │ │
│ │ - shadcn-nuxt │ │ - Business │ │
│ │ - Pinia Store │ │ Logic │ │
│ └──────────────────┘ └─────────┬────────┘ │
└────────────────────────────────────┼─────────────────┘
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
┌──────────▼──────────┐ ┌──────────▼──────────┐ ┌──────────▼──────────┐
│ Drizzle ORM │ │ BullMQ Queues │ │ Redis │
│ │ │ - Orders │ │ - Queue Storage │
│ │ │ - Product Sync │ │ - Sessions │
│ │ │ - Emails │ │ - Caching │
└──────────┬──────────┘ └──────────┬──────────┘ └─────────────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ PostgreSQL │ │ BullMQ Workers │
│ - Users │ │ - Order Worker │
│ - Products │ │ - Product Worker │
│ - Cart / Items │ │ - Email Worker │
│ - Orders / Items │ └─────────────────────┘
└─────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ External Services │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Cidaas │ │ NAV ERP │ │ X-API │ │
│ │ (Auth) │ │ (Products) │ │ (Content) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ PayPal │ │ Email │ │ Sentry │ │
│ │ (Payment) │ │ (SMTP) │ │ (Errors) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
3. Datenfluss-Diagramme
3.1 Benutzer-Registrierung & Login
┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ │ │ │ │
│ Client │ │ Nuxt │ │ Cidaas │
│ │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Click "Login" │ │
├──────────────────────────>│ │
│ │ │
│ │ 2. Redirect to Cidaas │
│ │ with Client ID │
│ ├──────────────────────────>│
│ │ │
│ │ │ 3. User authenticates
│ │ │ (email/password)
│ │ │
│ │ 4. Auth Code │
│<──────────────────────────┴───────────────────────────┤
│ │ │
│ 5. Send Auth Code │ │
├──────────────────────────>│ │
│ │ │
│ │ 6. Exchange Code for Token│
│ ├──────────────────────────>│
│ │ │
│ │ 7. Access Token + User Info│
│ │<──────────────────────────┤
│ │ │
│ │ 8. Create/Update User in DB
│ │ (PostgreSQL) │
│ │ │
│ 9. Set Session Cookie │ │
│<──────────────────────────┤ │
│ │ │
│ 10. Redirect to Dashboard │ │
│<──────────────────────────┤ │
│ │ │
Schritte:
- User klickt auf "Login"
- Nuxt leitet zu Cidaas weiter (OAuth2 Authorization)
- User authentifiziert sich bei Cidaas
- Cidaas redirected zurück mit Authorization Code
- Client sendet Code an Nuxt Server
- Nuxt tauscht Code gegen Access Token
- Nuxt erhält User-Info von Cidaas
- Nuxt erstellt/aktualisiert User-Profil in lokaler DB
- Nuxt setzt Session-Cookie
- User wird zur Homepage weitergeleitet
3.2 Produkt-Synchronisation (NAV ERP → App)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ │ │ │ │
│ NAV ERP │ │ Nuxt │ │PostgreSQL│
│ │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Product Update Event │ │
│ (e.g., new JK created) │ │
│ │ │
│ 2. HTTP POST │ │
│ /api/erp/products │ │
├──────────────────────────>│ │
│ Body: │ │
│ { │ │
│ nav_product_id, │ │
│ name, price, │ │
│ stock, ... │ │
│ } │ │
│ │ │
│ │ 3. Validate API Key │
│ │ │
│ │ 4. Validate Payload │
│ │ (Zod Schema) │
│ │ │
│ │ 5. Check if Product exists│
│ ├──────────────────────────>│
│ │ │
│ │ 6. Product exists? (Y/N) │
│ │<──────────────────────────┤
│ │ │
│ │ 7. INSERT or UPDATE │
│ ├──────────────────────────>│
│ │ │
│ │ 8. Success │
│ │<──────────────────────────┤
│ │ │
│ 9. HTTP 200 OK │ │
│<──────────────────────────┤ │
│ { │ │
│ success: true, │ │
│ product_id: "..." │ │
│ } │ │
│ │ │
Schritte:
- NAV ERP erkennt Produktänderung
- NAV sendet POST-Request an
/api/erp/products - Nuxt validiert API-Key
- Nuxt validiert Payload (Zod)
- Nuxt fügt Job zu BullMQ Queue hinzu (async!)
- Nuxt antwortet sofort mit 202 Accepted (NAV wartet nicht!)
- [Background] Worker holt Job aus Queue
- [Background] Worker prüft ob Produkt bereits existiert
- [Background] Worker führt INSERT oder UPDATE aus (Drizzle)
- [Background] DB bestätigt Operation
Vorteile:
- ✅ NAV ERP bekommt sofortige Response (nicht blockierend)
- ✅ Bulk-Import möglich (1000+ Produkte in Queue)
- ✅ Automatische Retries bei DB-Fehlern
- ✅ Rate Limiting verhindert DB-Überlastung
3.3 Checkout & Bestellung
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Nuxt │ │ PayPal │ │ X-API │ │ NAV ERP │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │ │
│ 1. Checkout │ │ │ │
├───────────────>│ │ │ │
│ │ │ │ │
│ │ 2. Create Order│ │ │
│ │ in DB (pending) │ │
│ │ │ │ │
│ │ 3. Create PayPal Order │ │
│ ├───────────────>│ │ │
│ │ │ │ │
│ │ 4. Order ID │ │ │
│ │<───────────────┤ │ │
│ │ │ │ │
│ 5. PayPal ID │ │ │ │
│<───────────────┤ │ │ │
│ │ │ │ │
│ 6. User redirected to PayPal │ │ │
├────────────────────────────────>│ │ │
│ │ │ │ │
│ │ │ 7. User pays │ │
│ │ │ │ │
│ 8. Redirect back to App │ │ │
│<────────────────────────────────┤ │ │
│ │ │ │ │
│ 9. Capture Payment │ │ │
├───────────────>│ │ │ │
│ │ 10. Capture │ │ │
│ ├───────────────>│ │ │
│ │ │ │ │
│ │ 11. Success │ │ │
│ │<───────────────┤ │ │
│ │ │ │ │
│ │ 12. Update Order Status (paid) │ │
│ │ │ │ │
│ │ 13. Transform Order to X-API Format │
│ │ │ │ │
│ │ 14. POST /shopware/order │ │
│ ├────────────────────────────────>│ │
│ │ │ │ │
│ │ │ │ 15. Forward to NAV (SOAP)
│ │ │ ├───────────────>│
│ │ │ │ │
│ │ │ │ 16. Process Order
│ │ │ │ │
│ │ │ │ 17. Confirmation
│ │ │ │<───────────────┤
│ │ │ │ │
│ │ 18. HTTP 200 OK│ │ │
│ │<────────────────────────────────┤ │
│ │ │ │ │
│ │ 19. Update Order Status (completed) │
│ │ │ │ │
│ │ 20. Send Confirmation Email │ │
│ │ │ │ │
│ 21. Show Success Page │ │ │
│<───────────────┤ │ │ │
│ │ │ │ │
Schritte:
- User klickt "Jetzt kaufen"
- If user has saved address: Form is pre-filled with saved data
- Else: Empty form is shown
- Nuxt erstellt Order in DB (Status: pending)
- If user checked "Save address": Update user profile with billing address
- Nuxt erstellt PayPal Order
- PayPal gibt Order-ID zurück
- Nuxt sendet Order-ID an Client
- Client redirected zu PayPal
- User zahlt bei PayPal
- PayPal redirected zurück zur App
- Client sendet "Capture Payment" Request
- Nuxt captured Payment bei PayPal
- PayPal bestätigt erfolgreiche Zahlung
- Nuxt updated Order-Status auf "paid"
- Nuxt fügt Job zu BullMQ Queue hinzu (async!)
- User sieht sofort Erfolgsseite mit Bestellnummer
- [Background] Worker holt Job aus Queue
- [Background] Worker transformiert Order in X-API Format (siehe 3.4)
- [Background] Worker sendet POST zu X-API
/shopware/order - [Background] X-API leitet Order an NAV ERP weiter (SOAP)
- [Background] NAV ERP verarbeitet Bestellung
- [Background] NAV sendet Bestätigung zurück an X-API
- [Background] X-API gibt HTTP 200 OK an Worker zurück
- [Background] Worker updated Order-Status auf "completed"
- [Background] Worker fügt Email-Job zu Queue hinzu
- [Background] Email-Worker sendet Bestätigungs-E-Mail
Vorteile:
- ✅ User wartet nicht auf X-API/NAV (sofortige Bestätigung)
- ✅ Automatische Retries bei X-API-Fehlern
- ✅ Keine verlorenen Bestellungen bei Server-Restart
- ✅ Entkoppelte Verarbeitung = robuster
3.4 X-API Order Transformation
Endpoint: POST https://x-api.experimenta.science/shopware/order
Environments:
- DEV:
https://x-api-dev.experimenta.science/shopware/order - STAGE:
https://x-api-stage.experimenta.science/shopware/order - LIVE:
https://x-api.experimenta.science/shopware/order
Authentication:
Die X-API ist mit HTTP Basic Authentication geschützt.
POST /shopware/order HTTP/1.1
Host: x-api.experimenta.science
Content-Type: application/json
Authorization: Basic <base64(username:password)>
{
"shopPOSOrder": { ... }
}
Credentials:
- Separate Credentials pro Environment (DEV, STAGE, LIVE)
- Gespeichert in Environment Variables:
X_API_USERNAME- Username für X-API Basic AuthX_API_PASSWORD- Password für X-API Basic AuthX_API_BASE_URL- Base URL des X-API Endpoints
Security:
- ⚠️ Credentials niemals im Code hardcoden
- ✅ Nur über HTTPS-Verbindungen
- ✅ In Production: Docker Secrets verwenden (nicht plain ENV vars)
- ✅ Credentials regelmäßig rotieren
- ✅ Getrennte Service-Accounts pro Environment
Order Mapping (our DB → X-API):
// Transform local order to X-API format
{
shopPOSOrder: {
// Document metadata
documentType: "Order", // Fixed for MVP
externalDocumentNo: order.orderNumber, // e.g., "EXP-2025-00001"
externalPOSReceiptNo: "", // Empty for web shop
salesChannel: "Shop", // Fixed
shoppingCartCompletion: order.createdAt.toISOString(), // ISO 8601 UTC
visitType: "Private", // Fixed for MVP (no institutions yet)
// Pricing (in cents!)
invoiceDiscountValue: 0, // No discounts in MVP
invoiceDiscPct: 0,
amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR → Cents
language: "DEU", // Fixed for German MVP
// Line items (one per cart item)
salesLine: order.items.map((item, index) => ({
type: "Item",
lineNo: String((index + 1) * 10000), // 10000, 20000, 30000, ...
no: item.product.navProductId, // Article number from NAV
variantCode: item.product.variantCode || "",
description: item.product.name.substring(0, 50),
description2: item.product.description?.substring(0, 50) || "",
quantity: item.quantity,
unitPrice: Math.round(item.priceSnapshot * 100), // EUR → Cents
vatPct: 7, // Standard 7% VAT for Jahreskarten
lineAmountIncludingVAT: Math.round(item.quantity * item.priceSnapshot * 100),
lineDiscountPct: 0,
lineDiscountAmount: 0,
seasonTicketCode: "", // Empty for MVP
couponCode: "", // Empty for MVP
visitorCategory: "120", // Code for "Jahreskarten-Inhabende"
ticketPriceType: "Jahreskarte Makerspace",
ticketCode: generateUniqueTicketCode(), // Generate unique code
// Annual pass details (required for Makerspace-JK!)
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, // YYYY-MM-DD format
eMail: user.email,
validFrom: calculateValidFromDate(), // Today or next day
}
})),
// Payment information
payment: [{
paymentEntryNo: 10000,
amount: Math.round(order.totalAmount * 100), // EUR → Cents
paymentType: "Shop PayPal", // Fixed for MVP
createdOn: order.paymentCompletedAt.toISOString(), // ISO 8601 UTC
reference: order.paymentId, // PayPal transaction ID (GUID format)
paymentId: order.paymentId, // PayPal order ID
}],
// Customer contact
personContact: {
experimentaAccountID: user.experimentaId, // Our experimenta_id from Cidaas!
eMail: user.email,
salutationCode: mapSalutation(user.salutation),
jobTitle: user.jobTitle || "",
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, // false for registered users, true for guest checkout
}
}
}
Important Data Transformations:
- Prices: EUR (Decimal) → Cents (Integer)
99.00EUR →9900Cents
- Dates: JavaScript Date → ISO 8601 UTC String
new Date()→"2025-10-28T14:30:00.000Z"
- Line Numbers: Sequential multiples of 10000
- Item 1:
"10000", Item 2:"20000", etc.
- Item 1:
- Salutation Mapping:
"male"→"HERR""female"→"FRAU""other"/null→"K_ANGABE"
Error Handling:
- Retry logic with exponential backoff (3 attempts)
- If X-API fails: Keep order as "paid" status, alert admin
- Manual retry option in admin panel (post-MVP)
- Log all requests/responses for debugging
Validation:
- Zod schema validation before sending
- Check all required fields present
- Validate formats (email, dates, UUIDs)
- Ensure amounts match (checksum validation)
3.5 Queue-Architektur (BullMQ + Redis)
Asynchrone Verarbeitung mit Job Queues für robuste und skalierbare Order- und Produkt-Synchronisation.
Übersicht
┌─────────────────────────────────────────────────────────────┐
│ Queue-basierte Architektur │
├─────────────────────────────────────────────────────────────┤
│ │
│ Producer (Nuxt API) Redis Queue Worker │
│ ┌────────────────┐ ┌──────────┐ ┌─────────┐│
│ │ POST /checkout │ │ │ │ Order ││
│ │ → add job ├────────>│ Orders ├──────>│ Worker ││
│ └────────────────┘ │ │ └─────────┘│
│ └──────────┘ │
│ ┌────────────────┐ ┌──────────┐ ┌─────────┐│
│ │ POST /erp/ │ │ Product │ │ Product ││
│ │ products ├────────>│ Sync ├──────>│ Worker ││
│ └────────────────┘ └──────────┘ └─────────┘│
│ ┌──────────┐ ┌─────────┐│
│ ┌────────────────┐ │ │ │ Email ││
│ │ Order complete ├────────>│ Emails ├──────>│ Worker ││
│ └────────────────┘ └──────────┘ └─────────┘│
│ │
└─────────────────────────────────────────────────────────────┘
Queue-Definitionen (MVP)
1. x-api-orders Queue
- Zweck: Bestellungen asynchron an X-API/NAV senden
- Producer: POST /api/payment/capture (nach PayPal Success)
- Worker: Order Worker
- Retry: 5 Versuche mit exponential backoff (2s, 4s, 8s, 16s, 32s)
- Priority: Hoch
- Persistenz: Redis AOF
2. product-sync Queue
- Zweck: Produkte von NAV ERP synchronisieren
- Producer: POST /api/erp/products
- Worker: Product Sync Worker
- Retry: 3 Versuche mit exponential backoff (1s, 3s, 9s)
- Priority: Mittel
- Rate Limiting: Max 10 Jobs/Sekunde (DB schonen)
3. emails Queue (Post-MVP)
- Zweck: E-Mails asynchron versenden
- Producer: Order Worker (nach X-API Success)
- Worker: Email Worker
- Retry: 3 Versuche mit fixed delay (5s)
- Priority: Niedrig
Redis-Konfiguration
Persistenz-Setup:
# Redis mit AOF + RDB für maximale Datensicherheit
redis-server \
--appendonly yes \
--appendfsync everysec \
--save 60 1000 \
--save 300 100 \
--save 900 1
Was bedeutet das:
--appendonly yes: Append-Only File aktiviert--appendfsync everysec: Jede Sekunde auf Disk schreiben--save: RDB Snapshots als Backup
Garantie: Max. 1 Sekunde Datenverlust bei Server-Crash
Worker-Implementierung
Order Worker (server/workers/order-worker.ts):
import { Worker } from 'bullmq'
const worker = new Worker(
'x-api-orders',
async (job) => {
const { orderId } = job.data
// 1. Fetch order from DB
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: { items: true, user: true },
})
if (!order) {
throw new Error(`Order ${orderId} not found`)
}
// 2. Transform to X-API format
const payload = transformOrderToXAPI(order)
// 3. Prepare Basic Auth header
const config = useRuntimeConfig()
const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString(
'base64'
)
// 4. Submit to X-API (with timeout and Basic Auth)
const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${authString}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30000), // 30s timeout
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`X-API error ${response.status}: ${errorText}`)
}
// 5. Update order status
await db.update(orders).set({ status: 'completed' }).where(eq(orders.id, orderId))
// 6. Queue confirmation email
await emailQueue.add('order-confirmation', {
orderId,
email: order.user.email,
})
return { success: true, orderId }
},
{
connection: { host: 'redis', port: 6379 },
concurrency: 5, // Max 5 parallel jobs
limiter: {
max: 10, // Max 10 jobs
duration: 1000, // per second
},
}
)
// Error handling
worker.on('failed', (job, error) => {
logger.error(`Order job ${job.id} failed:`, error)
if (job.attemptsMade >= 5) {
// Alert admin after max retries
alertAdmin(`Order ${job.data.orderId} failed permanently`)
}
})
worker.on('completed', (job) => {
logger.info(`Order ${job.data.orderId} submitted successfully`)
})
Product Sync Worker (server/workers/product-worker.ts):
import { Worker } from 'bullmq'
const worker = new Worker(
'product-sync',
async (job) => {
const product = job.data
// Upsert product
await db
.insert(products)
.values({
navProductId: product.nav_product_id,
name: product.name,
price: product.price,
stock: product.stock,
description: product.description,
imageUrl: product.image_url,
status: product.status,
})
.onConflictDoUpdate({
target: products.navProductId,
set: {
name: product.name,
price: product.price,
stock: product.stock,
description: product.description,
imageUrl: product.image_url,
status: product.status,
updatedAt: new Date(),
},
})
return { success: true, navProductId: product.nav_product_id }
},
{
connection: { host: 'redis', port: 6379 },
limiter: {
max: 10, // Max 10 products
duration: 1000, // per second (DB schonen!)
},
}
)
Monitoring: BullBoard
Dashboard-Setup (server/api/admin/queues.ts):
import { createBullBoard } from '@bull-board/api'
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
import { NuxtAdapter } from '@bull-board/nuxt'
const serverAdapter = new NuxtAdapter()
createBullBoard({
queues: [
new BullMQAdapter(orderQueue),
new BullMQAdapter(productSyncQueue),
new BullMQAdapter(emailQueue),
],
serverAdapter,
options: {
uiConfig: {
boardTitle: 'experimenta Queue Monitor',
},
},
})
// Protected with auth middleware
// Access: http://localhost:3000/admin/queues
Features:
- Real-time Job-Status
- Manual Retry
- Job Details & Logs
- Queue-Metriken (Waiting, Active, Completed, Failed)
- Fehler-Analyse
Fehlerbehandlung & Retry-Strategien
Exponential Backoff:
// Order Queue: 5 Versuche
await orderQueue.add('submit', data, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000, // 2s, 4s, 8s, 16s, 32s
},
removeOnComplete: 1000, // Keep last 1000 completed
removeOnFail: false, // Keep all failed (manual review)
})
Dead Letter Queue:
- Jobs die 5x fehlschlagen bleiben in Redis
- Admin-Alert bei permanenten Fehlern
- Manuelle Retry-Option in BullBoard
Skalierung
Mehrere Worker-Instanzen:
# docker-compose.yml
services:
worker-orders:
build: .
command: node server/workers/order-worker.js
deploy:
replicas: 2 # 2 Instanzen für höheren Durchsatz
environment:
- REDIS_URL=redis://redis:6379
Load Distribution:
- BullMQ verteilt Jobs automatisch über Worker
- Jeder Job wird nur 1x verarbeitet (Lock-Mechanismus)
Vorteile der Queue-Architektur
Robustheit:
- ✅ Keine verlorenen Jobs bei Server-Restart (Redis Persistenz)
- ✅ Automatische Retries bei temporären Fehlern
- ✅ Graceful Degradation (User sieht Erfolg, auch wenn X-API langsam)
Performance:
- ✅ Non-blocking API Endpoints (sofortige Response)
- ✅ Rate Limiting schützt DB vor Überlastung
- ✅ Parallelverarbeitung durch multiple Worker
Monitoring:
- ✅ BullBoard Dashboard für Ops-Team
- ✅ Fehler-Tracking pro Job
- ✅ Metriken für Durchsatz-Analyse
Skalierbarkeit:
- ✅ Horizontal skalierbar (mehr Worker = höherer Durchsatz)
- ✅ Priority Queues für wichtige Jobs
- ✅ Delayed Jobs für zeitgesteuerte Tasks
3.6 Authentication & Authorization (Cidaas OAuth2/OIDC)
Vollständige Authentifizierungsarchitektur mit OAuth2 Authorization Code Flow + PKCE.
Übersicht
┌──────────────────────────────────────────────────────────────────────┐
│ Authentication Architecture (Cidaas + Local DB) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Client │ HTTPS │ Nuxt 4 App │ OIDC │ Cidaas │ │
│ │ (Browser)│◄─────►│ (Server) │◄────►│ (OAuth2) │ │
│ └──────────┘ └───────┬──────┘ └──────────┘ │
│ │ │
│ │ Link via experimenta_id │
│ │ │
│ ┌──────▼────────┐ │
│ │ PostgreSQL │ │
│ │ - users │ │
│ │ - orders │ │
│ │ - ... │ │
│ └───────────────┘ │
│ │
│ Cidaas Rolle: ✅ Authentifizierung (Login/Registration) │
│ ❌ NICHT für Nutzerdaten-Speicherung │
│ │
│ Local DB Rolle: ✅ Nutzerprofile (Name, Adresse, Präferenzen) │
│ ✅ Bestellhistorie, Warenkorb │
│ ✅ Rollen & Berechtigungen (Post-MVP) │
│ │
└──────────────────────────────────────────────────────────────────────┘
Technologie-Stack
| Komponente | Technologie | Version |
|---|---|---|
| Auth Module | nuxt-auth-utils |
Latest |
| OAuth2 Provider | Cidaas (Widas) | OIDC-konform |
| OAuth2 Flow | Authorization Code + PKCE | RFC 7636 |
| Session Storage | Encrypted HTTP-only Cookies | AES-256-GCM |
| JWT Validation | jose |
Latest |
| Session Dauer | 30 Tage | Configurable |
Detailed Login Flow (OAuth2 + PKCE)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Browser │ HTTPS │ Nuxt 4 App │ OIDC │ Cidaas │ Link │ PostgreSQL │
│ (Client) │◄───────►│ (Server) │◄───────►│ (Identity) │ │ (User Store) │
└─────────────┘ └─────────────┘ └─────────────┘ └──────────────┘
│ │ │ │
│ 1. Navigate /auth │ │ │
│ (Login Tab) │ │ │
├──────────────────────►│ │ │
│ │ │ │
│ 2. Render Login Form │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 3. Submit email │ │ │
│ POST /api/auth/ │ │ │
│ login │ │ │
├──────────────────────►│ │ │
│ │ │ │
│ │ 4. Generate PKCE │ │
│ │ verifier (64chars)│ │
│ │ + challenge │ │
│ │ (SHA256 hash) │ │
│ │ │ │
│ │ 5. Generate state │ │
│ │ (CSRF token) │ │
│ │ │ │
│ │ 6. Store verifier + │ │
│ │ state in cookies │ │
│ │ (5min TTL) │ │
│ │ │ │
│ │ 7. Build OAuth2 URL: │ │
│ │ - client_id │ │
│ │ - redirect_uri │ │
│ │ - code_challenge │ │
│ │ - state │ │
│ │ - scope: openid │ │
│ │ profile email │ │
│ │ │ │
│ 8. Redirect to │ │ │
│ Cidaas AuthZ URL │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 9. Navigate to │ │ │
│ Cidaas login page │ │ │
├──────────────────────────────────────────────►│ │
│ │ │ │
│ 10. User enters │ │ │
│ email + password │ │ │
├──────────────────────────────────────────────►│ │
│ │ │ │
│ │ 11. Cidaas validates │ │
│ │ credentials │ │
│ │ │ │
│ 12. Redirect back │ │ │
│ to callback with │ │ │
│ auth code + state│ │ │
│◄──────────────────────────────────────────────┤ │
│ │ │ │
│ 13. GET /api/auth/ │ │ │
│ callback? │ │ │
│ code=xxx& │ │ │
│ state=yyy │ │ │
├──────────────────────►│ │ │
│ │ │ │
│ │ 14. Validate state │ │
│ │ (CSRF check) │ │
│ │ │ │
│ │ 15. Retrieve PKCE │ │
│ │ verifier from │ │
│ │ cookie │ │
│ │ │ │
│ │ 16. Exchange code │ │
│ │ + verifier for │ │
│ │ tokens │ │
│ │ POST /token-srv │ │
│ ├──────────────────────►│ │
│ │ │ │
│ │ 17. Validate code + │ │
│ │ verifier: │ │
│ │ SHA256(verifier) │ │
│ │ == challenge? │ │
│ │ │ │
│ │ 18. Return tokens: │ │
│ │ - access_token │ │
│ │ - id_token (JWT) │ │
│ │ - refresh_token │ │
│ │◄──────────────────────┤ │
│ │ │ │
│ │ 19. Validate ID token│ │
│ │ (JWT signature, │ │
│ │ exp, iss, aud) │ │
│ │ │ │
│ │ 20. Fetch UserInfo │ │
│ │ GET /users-srv │ │
│ ├──────────────────────►│ │
│ │ │ │
│ │ 21. User profile: │ │
│ │ { sub, email, │ │
│ │ given_name, │ │
│ │ family_name } │ │
│ │◄──────────────────────┤ │
│ │ │ │
│ │ 22. Query user by experimenta_id = sub │
│ ├─────────────────────────────────────────────────►│
│ │ │ │
│ │ 23. User exists? │
│ │◄─────────────────────────────────────────────────┤
│ │ │ │
│ │ 24. If new: INSERT user │
│ │ If exists: UPDATE updated_at │
│ ├─────────────────────────────────────────────────►│
│ │ │ │
│ │ 25. User record │
│ │◄─────────────────────────────────────────────────┤
│ │ │ │
│ │ 26. Create encrypted │ │
│ │ session cookie: │ │
│ │ { user: { │ │
│ │ id, email, │ │
│ │ firstName, │ │
│ │ lastName │ │
│ │ }} │ │
│ │ (AES-256-GCM) │ │
│ │ │ │
│ 27. Set-Cookie: │ │ │
│ experimenta- │ │ │
│ session=<enc>; │ │ │
│ HttpOnly; Secure;│ │ │
│ SameSite=Lax; │ │ │
│ Max-Age=2592000 │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 28. Redirect to / │ │ │
│ (Homepage) │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 29. Navigate Home │ │ │
│ (logged in!) │ │ │
├──────────────────────►│ │ │
│ │ │ │
Key Security Features:
-
PKCE (Proof Key for Code Exchange):
- Prevents authorization code interception attacks
- Verifier stored temporarily (5min), never transmitted
- Challenge sent to Cidaas, verified on token exchange
- Required even for confidential clients (defense in depth)
-
State Parameter:
- CSRF protection for OAuth2 callback
- Random 32-byte string generated per request
- Validated on callback before code exchange
-
Short-lived Temporary Cookies:
- PKCE verifier: 5min TTL (enough for auth flow)
- OAuth state: 5min TTL
- Prevents replay attacks
-
Encrypted Session Cookie:
- AES-256-GCM encryption via
nuxt-auth-utils - HTTP-only: Not accessible via JavaScript (XSS protection)
- Secure flag: Only transmitted over HTTPS
- SameSite=Lax: CSRF protection
- 30-day expiration (configurable)
- AES-256-GCM encryption via
Registration Flow
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │ │ Nuxt 4 App │ │ Cidaas │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ 1. Navigate /auth │ │
│ (Register Tab) │ │
├──────────────────────►│ │
│ │ │
│ 2. Render Register │ │
│ Form │ │
│◄──────────────────────┤ │
│ │ │
│ 3. Submit: │ │
│ - email │ │
│ - password │ │
│ - firstName │ │
│ - lastName │ │
│ POST /api/auth/ │ │
│ register │ │
├──────────────────────►│ │
│ │ │
│ │ 4. Validate input │
│ │ (Zod schema) │
│ │ │
│ │ 5. Call Cidaas │
│ │ Registration API: │
│ │ POST /users-srv/ │
│ │ register │
│ ├──────────────────────►│
│ │ │
│ │ 6. Create user │
│ │ Send verification │
│ │ email │
│ │ │
│ │ 7. Success response │
│ │◄──────────────────────┤
│ │ │
│ 8. Success message: │ │
│ "Please verify │ │
│ your email" │ │
│◄──────────────────────┤ │
│ │ │
│ 9. User clicks email │ │
│ verification link │ │
├──────────────────────────────────────────────►│
│ │ │
│ │ 10. Verify email │
│ │ Mark as verified │
│ │ │
│ 11. Confirmation │ │
│◄──────────────────────────────────────────────┤
│ │ │
│ 12. User can now │ │
│ login (follow │ │
│ login flow) │ │
│ │ │
Important Notes:
- User is created in Cidaas, NOT in local DB
- Local DB user record created on first login (via callback)
- Email verification required before login
- Password requirements enforced by Cidaas
- Registration can fail if email already exists
Session Management
Session Lifecycle:
// Session created on login (server/api/auth/callback.get.ts)
await setUserSession(event, {
user: {
id: user.id, // Local DB UUID
experimentaId: user.experimentaId, // Cidaas sub claim
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
loggedInAt: new Date().toISOString(),
})
// Session cookie:
// - Name: experimenta-session
// - Value: <encrypted-data>
// - Max-Age: 2592000 (30 days)
// - HttpOnly, Secure, SameSite=Lax
Client-Side Session Access:
// In Vue components (composables/useAuth.ts)
const { user, loggedIn } = useUserSession()
// user.value: { id, email, firstName, ... } or null
// loggedIn.value: boolean
Server-Side Session Validation:
// Protected API routes
export default defineEventHandler(async (event) => {
// Require authentication (throws 401 if not logged in)
const { user } = await requireUserSession(event)
// Access user data
const userId = user.id
// ...
})
Session Expiration:
- Default: 30 days (2592000 seconds)
- Configurable via
nuxt.config.ts - Auto-refresh: Session cookie updated on each request (sliding expiration)
- Logout:
clearUserSession()deletes cookie immediately
Protected Routes
Middleware Pattern:
// middleware/auth.ts
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>
JWT Token Validation
ID Token Verification (jose):
// server/utils/jwt.ts
import { jwtVerify, createRemoteJWKSet } from 'jose'
const config = useRuntimeConfig()
// JWKS (JSON Web Key Set) - Cidaas public keys
const JWKS = createRemoteJWKSet(
new URL(config.cidaas.jwksUrl) // /.well-known/jwks.json
)
export async function verifyIdToken(idToken: string) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: config.cidaas.issuer, // Must match Cidaas URL
audience: config.cidaas.clientId, // Must match our client ID
})
return payload // { sub, email, exp, iat, ... }
}
When Tokens Are Validated:
- ✅ On OAuth2 Callback - ID token validated before creating session
- ✅ Token Refresh (if implemented) - New tokens validated
- ❌ Every Request - NOT needed! Session cookie already validated by
nuxt-auth-utils
Token Storage:
- Access Token: Used once (UserInfo fetch), then discarded
- ID Token: Validated once, then discarded
- Refresh Token: NOT stored (stateless sessions preferred)
- Session Data: Stored in encrypted cookie (no tokens needed)
User Data Synchronization
Cidaas → PostgreSQL Mapping:
// server/api/auth/callback.get.ts
// Cidaas UserInfo response
const cidaasUser = {
sub: 'cidaas-user-123', // Unique Cidaas ID
email: 'user@example.com',
email_verified: true,
given_name: 'Max',
family_name: 'Mustermann',
updated_at: 1698765432,
}
// Map to local user record
await db.insert(users).values({
experimentaId: cidaasUser.sub, // Link to Cidaas!
email: cidaasUser.email,
firstName: cidaasUser.given_name,
lastName: cidaasUser.family_name,
})
Upsert Strategy:
- First Login: INSERT new user record
- Subsequent Logins: UPDATE
updated_attimestamp - No automatic sync: User profile changes in Cidaas not synced (by design)
- Manual sync: User can re-login to refresh profile data (if needed)
Security Architecture
Multi-Layer Security Model:
┌────────────────────────────────────────────────────────────┐
│ Security Layers │
├────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Transport (HTTPS) │
│ ├─ TLS 1.3 │
│ ├─ HSTS Headers (Strict-Transport-Security) │
│ └─ Certificate: Let's Encrypt │
│ │
│ Layer 2: OAuth2 Security (PKCE + State) │
│ ├─ PKCE: Authorization code interception prevention │
│ ├─ State: CSRF protection │
│ └─ Short-lived temporary cookies (5min) │
│ │
│ Layer 3: Session Security │
│ ├─ Encrypted cookies (AES-256-GCM) │
│ ├─ HTTP-only flag (XSS protection) │
│ ├─ Secure flag (HTTPS only) │
│ ├─ SameSite=Lax (CSRF protection) │
│ └─ 30-day expiration │
│ │
│ Layer 4: JWT Validation │
│ ├─ Signature verification (JWKS) │
│ ├─ Expiration check (exp claim) │
│ ├─ Issuer validation (iss claim) │
│ └─ Audience validation (aud claim) │
│ │
│ Layer 5: Input Validation │
│ ├─ Zod schemas (all endpoints) │
│ ├─ SQL injection prevention (Drizzle ORM) │
│ └─ XSS prevention (Vue auto-escaping) │
│ │
│ Layer 6: Rate Limiting │
│ ├─ Login: 5 attempts / 15min per IP │
│ ├─ Register: 3 attempts / hour per IP │
│ └─ Global: 100 req/min per IP (Nginx) │
│ │
└────────────────────────────────────────────────────────────┘
API Endpoints
Authentication Endpoints:
POST /api/auth/login - Initiate OAuth2 flow
GET /api/auth/callback - OAuth2 callback handler
POST /api/auth/register - Register new user
POST /api/auth/logout - Clear session
GET /api/auth/me - Get current user
Implementation Details:
See docs/CIDAAS_INTEGRATION.md for:
- Complete endpoint implementations
- Server utilities (PKCE, Cidaas API client, JWT validation)
- Client components (Login/Register forms)
- Middleware (auth, rate limiting)
- i18n translations
- Testing strategies
Environment Configuration
Required Environment Variables:
# Cidaas OAuth2 Configuration
CIDAAS_CLIENT_ID=<from-cidaas-admin>
CIDAAS_CLIENT_SECRET=<from-cidaas-admin>
CIDAAS_ISSUER=https://experimenta.cidaas.de
CIDAAS_AUTHORIZE_URL=https://experimenta.cidaas.de/authz-srv/authz
CIDAAS_TOKEN_URL=https://experimenta.cidaas.de/token-srv/token
CIDAAS_USERINFO_URL=https://experimenta.cidaas.de/users-srv/userinfo
CIDAAS_JWKS_URL=https://experimenta.cidaas.de/.well-known/jwks.json
CIDAAS_REDIRECT_URI=https://my.experimenta.science/api/auth/callback
# Session Encryption Secret (generate with: openssl rand -hex 32)
NUXT_SESSION_PASSWORD=<64-char-hex-secret>
Cidaas Admin Panel Setup:
- Create OAuth2 application in Cidaas
- Configure Grant Types:
authorization_code,refresh_token - Enable PKCE (code challenge method:
S256) - Set Redirect URIs (exact match required!)
- Configure Scopes:
openid,profile,email - Note Client ID & Secret
See docs/CIDAAS_INTEGRATION.md#setup-anleitung for detailed setup checklist.
Monitoring & Debugging
Logging:
// Auth-related logs (automatically logged)
logger.info({ userId, action: 'login' }, 'User logged in')
logger.error({ userId, error }, 'Login failed')
logger.info({ userId }, 'User logged out')
Debug Tips:
-
Inspect session cookie:
// Browser console document.cookie.split(';').find((c) => c.includes('experimenta-session')) -
Decode JWT (unverified):
const idToken = 'eyJ...' const payload = JSON.parse(atob(idToken.split('.')[1])) console.log(payload) -
Enable debug logging:
// nuxt.config.ts export default defineNuxtConfig({ nitro: { logLevel: 'debug' }, })
Fehlerbehandlung
Common Errors:
| Error | Cause | Solution |
|---|---|---|
| Invalid state parameter | State cookie expired or CSRF attack | Ensure cookies enabled, check cookie domain |
| PKCE verifier not found | Verifier cookie expired | Complete auth flow within 5 minutes |
| JWT verification failed | Invalid signature or expired token | Verify JWKS URL, check system clock |
| Token exchange failed | Invalid code or client credentials | Check client ID/secret, redirect URI exact match |
| Too many requests (429) | Rate limit exceeded | Wait for limit window to expire |
Error Handling Pattern:
// server/api/auth/callback.get.ts
try {
// OAuth2 flow
const tokens = await exchangeCodeForToken(code, verifier)
// ... create session
return sendRedirect(event, '/')
} catch (error) {
console.error('OAuth callback error:', error)
// Clean up cookies
deleteCookie(event, 'oauth_state')
deleteCookie(event, 'pkce_verifier')
// Redirect to login with error
return sendRedirect(event, '/auth?error=login_failed')
}
Performance Considerations
JWKS Caching:
- Cidaas JWKS endpoint cached for 1 hour (jose default)
- Reduces latency on token validation
- Auto-refreshes when keys rotated
Session Cookie Size:
- Typical size: ~500 bytes (encrypted)
- Transmitted on every request (overhead minimal)
- Consider Redis sessions for very large user objects
Database Queries:
- User lookup by
experimenta_id(indexed) - Single query on login (no joins needed)
- No additional queries per request (session-based)
4. Datenbankschema
4.1 ER-Diagramm (Textual)
┌─────────────────────┐
│ User │
├─────────────────────┤
│ id (PK) │
│ experimenta_id (UQ) │
│ email │
│ first_name │
│ last_name │
│ phone │
│ salutation │
│ date_of_birth │
│ street │
│ post_code │
│ city │
│ country_code │
│ created_at │
│ updated_at │
└──────────┬──────────┘
│
│ 1:N
│
┌──────────▼──────────┐ ┌─────────────────────┐
│ Cart │ │ Product │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ nav_product_id (UQ) │
│ session_id │ │ name │
│ created_at │ │ description │
│ updated_at │ │ price │
└──────────┬──────────┘ │ image_url │
│ │ stock │
│ 1:N │ status │
│ │ created_at │
┌──────────▼──────────┐ │ updated_at │
│ CartItem │ └──────────┬──────────┘
├─────────────────────┤ │
│ id (PK) │ │
│ cart_id (FK) │ │
│ product_id (FK) ────┼────────────────────┘
│ quantity │
│ price_snapshot │
│ created_at │
└─────────────────────┘
┌─────────────────────┐
│ Order │
├─────────────────────┤
│ id (PK) │
│ order_number (UQ) │
│ user_id (FK) ───────┼────> User
│ status │
│ total_amount │
│ payment_method │
│ payment_id │
│ billing_address │
│ created_at │
│ updated_at │
└──────────┬──────────┘
│
│ 1:N
│
┌──────────▼──────────┐
│ OrderItem │
├─────────────────────┤
│ id (PK) │
│ order_id (FK) │
│ product_id (FK) ────┼────> Product
│ quantity │
│ price │
│ created_at │
└─────────────────────┘
┌─────────────────────┐
│ Role │
├─────────────────────┤
│ id (PK) │
│ code (UQ) │ ('private', 'educator', 'company')
│ display_name │
│ description │
│ requires_approval │
│ sort_order │
│ active │
│ created_at │
│ updated_at │
└──────────┬──────────┘
│
│ M:N
│
┌──────────▼──────────┐ ┌─────────────────────┐
│ UserRole │ │ ProductRoleVis... │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) ───────┼────> │ product_id (FK) ────┼────> Product
│ role_id (FK) ───────┼────> │ role_id (FK) ───────┼────> Role
│ status │ │ created_at │
│ organization_name │ └─────────────────────┘
│ admin_notes │
│ status_history │ (JSONB)
│ created_at │
│ updated_at │
└─────────────────────┘
Rollen-System (MVP - Datenbankstruktur):
- roles: Rollen-Definitionen (private, educator, company)
- user_roles: Many-to-Many User ↔ Rollen mit Antrags-Workflow (vorbereitet für Phase 2/3)
- product_role_visibility: Many-to-Many Produkt ↔ Rollen (Sichtbarkeitssteuerung)
Opt-in Sichtbarkeit:
- Produkte OHNE
product_role_visibilityEinträge sind für NIEMANDEN sichtbar - Produkte MIT Einträgen sind nur für User mit passender
approvedRolle sichtbar
4.2 Drizzle Schema Definition
// server/database/schema.ts
import {
pgTable,
uuid,
varchar,
timestamp,
decimal,
integer,
text,
jsonb,
pgEnum,
} from 'drizzle-orm/pg-core'
// Enums
export const orderStatusEnum = pgEnum('order_status', [
'pending',
'paid',
'processing',
'completed',
'cancelled',
])
export const productStatusEnum = pgEnum('product_status', ['active', 'inactive'])
export const paymentMethodEnum = pgEnum('payment_method', ['paypal'])
// Users
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
experimentaId: varchar('experimenta_id', { length: 255 }).notNull().unique(),
email: varchar('email', { length: 255 }).notNull().unique(),
firstName: varchar('first_name', { length: 100 }),
lastName: varchar('last_name', { length: 100 }),
phone: varchar('phone', { length: 20 }),
// Billing address fields (optional - filled during checkout or profile edit)
salutation: varchar('salutation', { length: 20 }), // 'male', 'female', 'other'
dateOfBirth: date('date_of_birth'), // Required for annual passes
street: varchar('street', { length: 100 }),
postCode: varchar('post_code', { length: 20 }),
city: varchar('city', { length: 100 }),
countryCode: varchar('country_code', { length: 10 }).default('DE'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
// Products
export const products = pgTable('products', {
id: uuid('id').defaultRandom().primaryKey(),
navProductId: varchar('nav_product_id', { length: 100 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
price: decimal('price', { precision: 10, scale: 2 }).notNull(),
imageUrl: varchar('image_url', { length: 500 }),
stock: integer('stock').default(0).notNull(),
status: productStatusEnum('status').default('active').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
// Carts
export const carts = pgTable('carts', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
sessionId: varchar('session_id', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
// Cart Items
export const cartItems = pgTable('cart_items', {
id: uuid('id').defaultRandom().primaryKey(),
cartId: uuid('cart_id')
.references(() => carts.id, { onDelete: 'cascade' })
.notNull(),
productId: uuid('product_id')
.references(() => products.id)
.notNull(),
quantity: integer('quantity').default(1).notNull(),
priceSnapshot: decimal('price_snapshot', { precision: 10, scale: 2 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
// Orders
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
orderNumber: varchar('order_number', { length: 50 }).notNull().unique(),
userId: uuid('user_id')
.references(() => users.id)
.notNull(),
status: orderStatusEnum('status').default('pending').notNull(),
totalAmount: decimal('total_amount', { precision: 10, scale: 2 }).notNull(),
paymentMethod: paymentMethodEnum('payment_method').notNull(),
paymentId: varchar('payment_id', { length: 255 }),
billingAddress: jsonb('billing_address'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
// Order Items
export const orderItems = pgTable('order_items', {
id: uuid('id').defaultRandom().primaryKey(),
orderId: uuid('order_id')
.references(() => orders.id, { onDelete: 'cascade' })
.notNull(),
productId: uuid('product_id')
.references(() => products.id)
.notNull(),
quantity: integer('quantity').default(1).notNull(),
price: decimal('price', { precision: 10, scale: 2 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
5. API-Architektur
5.1 REST API Struktur
/api
/auth
/login POST - Initiate Cidaas OAuth
/callback GET - OAuth callback handler
/logout POST - End session
/me GET - Get current user
/products
/ GET - List all products
/:id GET - Get product details
/cart
/ GET - Get current cart
/items POST - Add item to cart
/items/:id PATCH - Update item quantity
/items/:id DELETE - Remove item from cart
/orders
/ GET - List user's orders
/ POST - Create new order
/:id GET - Get order details
/payment
/paypal
/create POST - Create PayPal order
/capture POST - Capture PayPal payment
/webhook POST - PayPal webhook handler
/erp
/products POST - Receive product updates from NAV
/stock POST - Update stock levels
5.2 API Authentication
Client-to-Server:
- Session-based (HTTP-only Cookie)
- Cookie:
__session(encrypted) - Nuxt
useSession()composable
ERP-to-Server:
- API Key Authentication
- Header:
X-API-Key: <secret> - Stored in Environment Variables
Cidaas OAuth:
- OAuth 2.0 Authorization Code Flow
- PKCE for additional security
6. Sicherheitsarchitektur
6.1 Authentifizierung & Autorisierung
┌─────────────────────────────────────────────────────────────┐
│ Security Layers │
├─────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Transport Security (HTTPS/TLS 1.3) │
│ ├─ Let's Encrypt SSL Certificate │
│ ├─ HSTS Headers │
│ └─ Secure Cookies (httpOnly, secure, sameSite) │
│ │
│ Layer 2: Authentication (Cidaas OAuth2) │
│ ├─ Authorization Code Flow + PKCE │
│ ├─ JWT Token Validation (jose) │
│ └─ Session Management (encrypted cookies) │
│ │
│ Layer 3: Authorization (Route Guards) │
│ ├─ Middleware: requireAuth() │
│ ├─ API Middleware: validateApiKey() │
│ └─ Resource Ownership Checks │
│ │
│ Layer 4: Input Validation (Zod) │
│ ├─ Request Body Validation │
│ ├─ Query Params Validation │
│ └─ SQL Injection Prevention (Drizzle) │
│ │
│ Layer 5: Rate Limiting │
│ ├─ Nginx: 100 req/min per IP │
│ ├─ API Endpoints: Custom limits │
│ └─ Redis-based tracking (optional) │
│ │
│ Layer 6: CORS & CSP │
│ ├─ CORS: Only experimenta.science │
│ ├─ CSP Headers │
│ └─ X-Frame-Options, X-Content-Type-Options │
│ │
└─────────────────────────────────────────────────────────────┘
6.2 Secrets Management
Environment Variables:
# .env (NEVER commit!)
DATABASE_URL=postgresql://user:pass@db:5432/experimenta
# Cidaas Authentication
CIDAAS_CLIENT_ID=xxx
CIDAAS_CLIENT_SECRET=xxx
# PayPal Payment
PAYPAL_CLIENT_ID=xxx
PAYPAL_CLIENT_SECRET=xxx
# X-API Integration (Basic Auth)
X_API_BASE_URL=https://x-api-dev.experimenta.science
X_API_USERNAME=shop_user_dev
X_API_PASSWORD=xxx
# Internal API Security
ERP_API_KEY=xxx
SESSION_SECRET=xxx
# Email (Optional)
SMTP_USER=xxx
SMTP_PASS=xxx
# Monitoring (Optional)
SENTRY_DSN=xxx
Docker Secrets (Production):
# docker-compose.yml
services:
app:
environment:
- DATABASE_URL_FILE=/run/secrets/db_url
secrets:
- db_url
- cidaas_secret
- paypal_secret
- xapi_username
- xapi_password
worker:
# Workers need X-API credentials for order submission
secrets:
- xapi_username
- xapi_password
secrets:
db_url:
file: ./secrets/db_url.txt
cidaas_secret:
file: ./secrets/cidaas.txt
paypal_secret:
file: ./secrets/paypal.txt
xapi_username:
file: ./secrets/xapi_username.txt
xapi_password:
file: ./secrets/xapi_password.txt
7. Deployment-Architektur
7.1 Production Setup (Hetzner)
┌─────────────────────────────────────────────────────────────┐
│ Hetzner Dedicated Server / VPS │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Proxmox Virtualization │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ Docker Container (Production) │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │ │
│ │ │ │ Nginx (Reverse Proxy) │ │ │ │
│ │ │ │ - SSL Termination (Let's Encrypt) │ │ │ │
│ │ │ │ - Load Balancing │ │ │ │
│ │ │ │ - Rate Limiting │ │ │ │
│ │ │ │ - Static File Serving │ │ │ │
│ │ │ └─────────────┬───────────────────────────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ ┌─────────────▼───────────────────────────┐ │ │ │
│ │ │ │ Nuxt App (Node.js) │ │ │ │
│ │ │ │ - Port 3000 │ │ │ │
│ │ │ │ - PM2 Process Manager (optional) │ │ │ │
│ │ │ │ - Multiple Instances (Clustering) │ │ │ │
│ │ │ └─────────────┬───────────────────────────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ ┌─────────────▼───────────────────────────┐ │ │ │
│ │ │ │ PostgreSQL 16 │ │ │ │
│ │ │ │ - Port 5432 (internal only) │ │ │ │
│ │ │ │ - Persistent Volume │ │ │ │
│ │ │ │ - Daily Backups │ │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ Optional: │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │ │
│ │ │ │ Redis (Caching/Sessions) │ │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
7.2 Docker Compose (Production)
# docker-compose.prod.yml
version: '3.9'
services:
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./public:/usr/share/nginx/html:ro
depends_on:
- app
restart: always
app:
build:
context: .
dockerfile: Dockerfile
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=redis://redis:6379
- CIDAAS_CLIENT_ID=${CIDAAS_CLIENT_ID}
- CIDAAS_CLIENT_SECRET=${CIDAAS_CLIENT_SECRET}
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
- PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}
depends_on:
- db
- redis
restart: always
deploy:
replicas: 2 # For load balancing
worker:
build:
context: .
dockerfile: Dockerfile
command: node server/workers/index.js
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=redis://redis:6379
- X_API_BASE_URL=${X_API_BASE_URL}
depends_on:
- db
- redis
restart: always
deploy:
replicas: 2 # 2 Worker-Instanzen für Parallelverarbeitung
redis:
image: redis:7-alpine
command: >
redis-server
--appendonly yes
--appendfsync everysec
--save 60 1000
--save 300 100
--save 900 1
volumes:
- redis_data:/data
restart: always
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 3
db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=experimenta
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups:/backups
restart: always
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${DB_USER}']
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
driver: local
redis_data:
driver: local
8. CI/CD Pipeline
8.1 GitLab CI/CD Flow
┌──────────────────────────────────────────────────────────────┐
│ GitLab CI/CD Pipeline │
├──────────────────────────────────────────────────────────────┤
│ │
│ Stage 1: Build │
│ ├─ Install Dependencies (pnpm install) │
│ ├─ Build Nuxt App (npm run build) │
│ ├─ Build Docker Image │
│ └─ Push to Container Registry │
│ │
│ Stage 2: Test │
│ ├─ Lint (ESLint + Prettier) │
│ ├─ Unit Tests (Vitest) │
│ ├─ Integration Tests │
│ └─ E2E Tests (Playwright) - on Staging │
│ │
│ Stage 3: Deploy Staging (Auto) │
│ ├─ Pull Docker Image │
│ ├─ Run Database Migrations │
│ ├─ Deploy to Staging Environment │
│ └─ Smoke Tests │
│ │
│ Stage 4: Deploy Production (Manual) │
│ ├─ Manual Approval Required │
│ ├─ Pull Docker Image │
│ ├─ Run Database Migrations │
│ ├─ Blue-Green Deployment │
│ │ ├─ Start new container (green) │
│ │ ├─ Health check │
│ │ ├─ Switch traffic to green │
│ │ └─ Stop old container (blue) │
│ └─ Post-deployment Tests │
│ │
└──────────────────────────────────────────────────────────────┘
8.2 GitLab CI Configuration
# .gitlab-ci.yml
stages:
- build
- test
- deploy_staging
- deploy_production
variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
build:
stage: build
image: node:20-alpine
script:
- pnpm install --frozen-lockfile
- pnpm run build
- docker build -t $DOCKER_IMAGE .
- docker tag $DOCKER_IMAGE $DOCKER_IMAGE_LATEST
- docker push $DOCKER_IMAGE
- docker push $DOCKER_IMAGE_LATEST
only:
- main
- develop
test:
stage: test
image: node:20-alpine
script:
- pnpm install --frozen-lockfile
- pnpm run lint
- pnpm run test
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
deploy_staging:
stage: deploy_staging
script:
- ssh deployer@staging.experimenta.science "
docker pull $DOCKER_IMAGE &&
cd /opt/experimenta &&
docker-compose -f docker-compose.staging.yml down &&
docker-compose -f docker-compose.staging.yml up -d
"
environment:
name: staging
url: https://staging.my.experimenta.science
only:
- develop
deploy_production:
stage: deploy_production
script:
- ssh deployer@my.experimenta.science "
docker pull $DOCKER_IMAGE &&
cd /opt/experimenta &&
./scripts/blue-green-deploy.sh $DOCKER_IMAGE
"
environment:
name: production
url: https://my.experimenta.science
when: manual
only:
- main
9. Monitoring & Observability
9.1 Logging-Strategie
Strukturiertes Logging:
// server/utils/logger.ts
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => {
return { level: label }
},
},
timestamp: pino.stdTimeFunctions.isoTime,
})
// Usage
logger.info({ userId: '123', action: 'checkout' }, 'User completed checkout')
logger.error({ err, userId: '123' }, 'Payment failed')
Log-Aggregation (Optional):
- Loki + Grafana
- Elasticsearch + Kibana
- CloudWatch Logs (wenn auf AWS)
9.2 Error Tracking (Sentry)
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/sentry'],
sentry: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracing: {
tracesSampleRate: 0.1, // 10% of transactions
},
},
})
9.3 Performance Monitoring
Metrics to Track:
- Response Time (p50, p95, p99)
- Error Rate
- Request Rate
- Database Query Time
- Cache Hit Rate (if Redis)
- Active Users
Tools:
- Sentry Performance Monitoring
- Grafana + Prometheus (self-hosted)
- New Relic (commercial)
10. Backup & Disaster Recovery
10.1 Backup-Strategie
Datenbank Backups:
# Cron Job: Täglich um 2 Uhr
0 2 * * * docker exec experimenta-db pg_dump -U user experimenta > /backups/db-$(date +\%Y\%m\%d).sql
Retention Policy:
- Täglich: 7 Tage
- Wöchentlich: 4 Wochen
- Monatlich: 12 Monate
Backup-Speicherort:
- Lokal:
/backups(auf Host) - Remote: Hetzner Storage Box (SFTP)
- Optional: AWS S3 / Backblaze B2
10.2 Disaster Recovery Plan
RTO (Recovery Time Objective): 4 Stunden RPO (Recovery Point Objective): 24 Stunden (tägliche Backups)
Recovery Steps:
- Neuen Server bereitstellen (Proxmox oder Hetzner Cloud)
- Docker & Docker Compose installieren
- PostgreSQL Container starten
- Letztes Backup wiederherstellen
- App Container starten
- DNS auf neuen Server umstellen
- SSL-Zertifikat neu ausstellen (Let's Encrypt)
11. Skalierungsstrategie
11.1 Vertikale Skalierung (Short-term)
- Mehr CPU/RAM für Server
- Upgrade: von 4 Cores / 8GB auf 8 Cores / 16GB
- PostgreSQL Tuning (shared_buffers, work_mem)
11.2 Horizontale Skalierung (Long-term)
Load Balancer:
┌────────────────┐
│ Load Balancer │
│ (Nginx) │
└───────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐
│ App │ │ App │ │ App │
│ Instance 1│ │ Instance 2│ │ Instance 3│
└──────┬─────┘ └──────┬─────┘ └──────┬─────┘
│ │ │
└───────────────┼───────────────┘
│
┌──────▼─────┐
│ PostgreSQL │
│ (Primary) │
└──────┬─────┘
│
┌──────▼─────┐
│ PostgreSQL │
│ (Replica) │
└────────────┘
Schritte:
- Mehrere App-Instanzen (Docker Replicas)
- PostgreSQL Read Replicas
- Redis für Session-Storage (shared state)
- CDN für Static Assets (Cloudflare)
12. Zusammenfassung
Key Architectural Decisions:
| Aspekt | Entscheidung | Begründung |
|---|---|---|
| Framework | Nuxt 4 | Full-Stack, SSR, TypeScript |
| UI | shadcn-nuxt | Maximale Flexibilität |
| Database | PostgreSQL | ACID, bewährt für E-Commerce |
| ORM | Drizzle | Performance, TypeScript-first |
| Auth | Cidaas + Local DB | Unternehmens-Standard + Flexibilität |
| Payment | PayPal | Weit verbreitet, einfach |
| i18n | @nuxtjs/i18n | Deutsch + English, SEO-optimiert |
| Deployment | Docker | Portabilität, Konsistenz |
| Hosting | Hetzner | Kostengünstig, EU-basiert |
| CI/CD | GitLab | Bereits intern vorhanden |
Ende des Dokuments