2242 lines
98 KiB
Markdown
2242 lines
98 KiB
Markdown
# 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:**
|
|
|
|
1. User klickt auf "Login"
|
|
2. Nuxt leitet zu Cidaas weiter (OAuth2 Authorization)
|
|
3. User authentifiziert sich bei Cidaas
|
|
4. Cidaas redirected zurück mit Authorization Code
|
|
5. Client sendet Code an Nuxt Server
|
|
6. Nuxt tauscht Code gegen Access Token
|
|
7. Nuxt erhält User-Info von Cidaas
|
|
8. Nuxt erstellt/aktualisiert User-Profil in lokaler DB
|
|
9. Nuxt setzt Session-Cookie
|
|
10. 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:**
|
|
|
|
1. NAV ERP erkennt Produktänderung
|
|
2. NAV sendet POST-Request an `/api/erp/products`
|
|
3. Nuxt validiert API-Key
|
|
4. Nuxt validiert Payload (Zod)
|
|
5. **Nuxt fügt Job zu BullMQ Queue hinzu (async!)**
|
|
6. **Nuxt antwortet sofort mit 202 Accepted** (NAV wartet nicht!)
|
|
7. **[Background] Worker holt Job aus Queue**
|
|
8. **[Background] Worker prüft ob Produkt bereits existiert**
|
|
9. **[Background] Worker führt INSERT oder UPDATE aus (Drizzle)**
|
|
10. **[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:**
|
|
|
|
1. User klickt "Jetzt kaufen"
|
|
- **If user has saved address:** Form is pre-filled with saved data
|
|
- **Else:** Empty form is shown
|
|
2. Nuxt erstellt Order in DB (Status: pending)
|
|
- **If user checked "Save address":** Update user profile with billing address
|
|
3. Nuxt erstellt PayPal Order
|
|
4. PayPal gibt Order-ID zurück
|
|
5. Nuxt sendet Order-ID an Client
|
|
6. Client redirected zu PayPal
|
|
7. User zahlt bei PayPal
|
|
8. PayPal redirected zurück zur App
|
|
9. Client sendet "Capture Payment" Request
|
|
10. Nuxt captured Payment bei PayPal
|
|
11. PayPal bestätigt erfolgreiche Zahlung
|
|
12. Nuxt updated Order-Status auf "paid"
|
|
13. **Nuxt fügt Job zu BullMQ Queue hinzu (async!)**
|
|
14. **User sieht sofort Erfolgsseite** mit Bestellnummer
|
|
15. **[Background] Worker holt Job aus Queue**
|
|
16. **[Background] Worker transformiert Order in X-API Format (siehe 3.4)**
|
|
17. **[Background] Worker sendet POST zu X-API `/shopware/order`**
|
|
18. **[Background] X-API leitet Order an NAV ERP weiter (SOAP)**
|
|
19. **[Background] NAV ERP verarbeitet Bestellung**
|
|
20. **[Background] NAV sendet Bestätigung zurück an X-API**
|
|
21. **[Background] X-API gibt HTTP 200 OK an Worker zurück**
|
|
22. **[Background] Worker updated Order-Status auf "completed"**
|
|
23. **[Background] Worker fügt Email-Job zu Queue hinzu**
|
|
24. **[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.
|
|
|
|
```http
|
|
POST /shopware/order HTTP/1.1
|
|
Host: x-api.experimenta.science
|
|
Content-Type: application/json
|
|
Authorization: Basic <base64(username:password)>
|
|
|
|
```
|
|
|
|
**Credentials:**
|
|
|
|
- Separate Credentials pro Environment (DEV, STAGE, LIVE)
|
|
- Gespeichert in Environment Variables:
|
|
- `X_API_USERNAME` - Username für X-API Basic Auth
|
|
- `X_API_PASSWORD` - Password für X-API Basic Auth
|
|
- `X_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):**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
1. **Prices:** EUR (Decimal) → Cents (Integer)
|
|
- `99.00` EUR → `9900` Cents
|
|
2. **Dates:** JavaScript Date → ISO 8601 UTC String
|
|
- `new Date()` → `"2025-10-28T14:30:00.000Z"`
|
|
3. **Line Numbers:** Sequential multiples of 10000
|
|
- Item 1: `"10000"`, Item 2: `"20000"`, etc.
|
|
4. **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:**
|
|
|
|
```bash
|
|
# 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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```yaml
|
|
# 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:**
|
|
|
|
1. **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)
|
|
|
|
2. **State Parameter:**
|
|
- CSRF protection for OAuth2 callback
|
|
- Random 32-byte string generated per request
|
|
- Validated on callback before code exchange
|
|
|
|
3. **Short-lived Temporary Cookies:**
|
|
- PKCE verifier: 5min TTL (enough for auth flow)
|
|
- OAuth state: 5min TTL
|
|
- Prevents replay attacks
|
|
|
|
4. **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)
|
|
|
|
#### 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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// In Vue components (composables/useAuth.ts)
|
|
const { user, loggedIn } = useUserSession()
|
|
|
|
// user.value: { id, email, firstName, ... } or null
|
|
// loggedIn.value: boolean
|
|
```
|
|
|
|
**Server-Side Session Validation:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```vue
|
|
<!-- 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):**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
1. ✅ **On OAuth2 Callback** - ID token validated before creating session
|
|
2. ✅ **Token Refresh** (if implemented) - New tokens validated
|
|
3. ❌ **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:**
|
|
|
|
```typescript
|
|
// 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_at` timestamp
|
|
- **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`](./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:**
|
|
|
|
```bash
|
|
# 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_SECRET=<64-char-hex-secret>
|
|
```
|
|
|
|
**Cidaas Admin Panel Setup:**
|
|
|
|
1. Create OAuth2 application in Cidaas
|
|
2. Configure Grant Types: `authorization_code`, `refresh_token`
|
|
3. Enable PKCE (code challenge method: `S256`)
|
|
4. Set Redirect URIs (exact match required!)
|
|
5. Configure Scopes: `openid`, `profile`, `email`
|
|
6. Note Client ID & Secret
|
|
|
|
See [`docs/CIDAAS_INTEGRATION.md#setup-anleitung`](./CIDAAS_INTEGRATION.md#setup-anleitung) for detailed setup checklist.
|
|
|
|
#### Monitoring & Debugging
|
|
|
|
**Logging:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
1. **Inspect session cookie:**
|
|
|
|
```javascript
|
|
// Browser console
|
|
document.cookie.split(';').find((c) => c.includes('experimenta-session'))
|
|
```
|
|
|
|
2. **Decode JWT (unverified):**
|
|
|
|
```javascript
|
|
const idToken = 'eyJ...'
|
|
const payload = JSON.parse(atob(idToken.split('.')[1]))
|
|
console.log(payload)
|
|
```
|
|
|
|
3. **Enable debug logging:**
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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 │
|
|
└─────────────────────┘
|
|
```
|
|
|
|
### 4.2 Drizzle Schema Definition
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```bash
|
|
# .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):**
|
|
|
|
```yaml
|
|
# 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)
|
|
|
|
```yaml
|
|
# 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
|
|
|
|
```yaml
|
|
# .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:**
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```bash
|
|
# 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:**
|
|
|
|
1. Neuen Server bereitstellen (Proxmox oder Hetzner Cloud)
|
|
2. Docker & Docker Compose installieren
|
|
3. PostgreSQL Container starten
|
|
4. Letztes Backup wiederherstellen
|
|
5. App Container starten
|
|
6. DNS auf neuen Server umstellen
|
|
7. 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:**
|
|
|
|
1. Mehrere App-Instanzen (Docker Replicas)
|
|
2. PostgreSQL Read Replicas
|
|
3. Redis für Session-Storage (shared state)
|
|
4. 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**
|