feat: GPU offload, signup limit, load tests & capacity planning

- Route all AI workloads (Ollama, STT, TTS, Image Gen) to GPU server
  (192.168.178.11) via LAN instead of host.docker.internal
- Upgrade default model to gemma3:12b and max concurrent to 5
- Add daily signup limit service (MAX_DAILY_SIGNUPS env var)
- Add GET /api/v1/auth/signup-status public endpoint
- Add k6 load test suite (web-apps, auth, sync-websocket, ollama)
- Add capacity planning documentation
- Fix: add eslint-config to sveltekit-base and calendar Dockerfiles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 21:14:24 +01:00
parent 16367384c7
commit 9276d9a212
12 changed files with 683 additions and 14 deletions

View file

@ -23,6 +23,7 @@ COPY pnpm-lock.yaml ./
# --- AUTO-GENERATED COPY STATEMENTS (do not edit manually) ---
COPY patches/ ./patches/
COPY packages/feedback ./packages/feedback
COPY packages/eslint-config ./packages/eslint-config
COPY packages/help ./packages/help
COPY packages/local-store ./packages/local-store
COPY packages/shared-api-client ./packages/shared-api-client

View file

@ -268,6 +268,7 @@ services:
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
SMTP_PASS: ${SMTP_PASSWORD}
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
MAX_DAILY_SIGNUPS: ${MAX_DAILY_SIGNUPS:-0}
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
ports:
- "3001:3001"
@ -419,8 +420,8 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
MANA_CORE_AUTH_URL: http://mana-auth:3001
SEARCH_SERVICE_URL: http://mana-search:3012
STT_SERVICE_URL: http://host.docker.internal:3026
TTS_SERVICE_URL: http://host.docker.internal:3022
STT_SERVICE_URL: ${STT_SERVICE_URL:-http://192.168.178.11:3020}
TTS_SERVICE_URL: ${TTS_SERVICE_URL:-http://192.168.178.11:3022}
CORS_ORIGINS: https://api.mana.how,https://mana.how
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
ports:
@ -755,12 +756,12 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
# Voice services
STT_URL: http://host.docker.internal:3026
TTS_URL: http://host.docker.internal:3022
# AI
OLLAMA_URL: http://host.docker.internal:11434
OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b}
# Voice services (GPU server via LAN)
STT_URL: ${STT_SERVICE_URL:-http://192.168.178.11:3020}
TTS_URL: ${TTS_SERVICE_URL:-http://192.168.178.11:3022}
# AI (GPU server via LAN)
OLLAMA_URL: ${OLLAMA_URL:-http://192.168.178.11:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:12b}
# Plugin tokens (all 21 bot identities)
MATRIX_MANA_BOT_TOKEN: ${MATRIX_MANA_BOT_TOKEN}
MATRIX_MANA_BOT_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-}
@ -1214,7 +1215,7 @@ services:
DB_USER: postgres
MANA_CORE_AUTH_URL: http://mana-auth:3001
REPLICATE_API_TOKEN: ${REPLICATE_API_TOKEN}
IMAGE_GEN_SERVICE_URL: http://host.docker.internal:3025
IMAGE_GEN_SERVICE_URL: ${IMAGE_GEN_SERVICE_URL:-http://192.168.178.11:3023}
APP_ID: picture-app
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
S3_ENDPOINT: http://minio:9000
@ -1298,8 +1299,8 @@ services:
environment:
PORT: 3020
LOG_LEVEL: info
OLLAMA_URL: http://host.docker.internal:11434
OLLAMA_DEFAULT_MODEL: gemma3:4b
OLLAMA_URL: ${OLLAMA_URL:-http://192.168.178.11:11434}
OLLAMA_DEFAULT_MODEL: ${OLLAMA_MODEL:-gemma3:12b}
OLLAMA_TIMEOUT: 120
REDIS_URL: redis://redis:6379
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
@ -1308,7 +1309,7 @@ services:
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
GOOGLE_DEFAULT_MODEL: gemini-2.0-flash
AUTO_FALLBACK_ENABLED: "true"
OLLAMA_MAX_CONCURRENT: 3
OLLAMA_MAX_CONCURRENT: 5
CORS_ORIGINS: https://playground.mana.how,https://mana.how,https://chat.mana.how
extra_hosts:
- "host.docker.internal:host-gateway"

View file

@ -54,6 +54,7 @@ COPY packages/shared-types ./packages/shared-types
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
COPY packages/shared-vite-config ./packages/shared-vite-config
COPY packages/eslint-config ./packages/eslint-config
COPY packages/shared-hono ./packages/shared-hono
COPY packages/shared-storage ./packages/shared-storage
COPY packages/shared-landing-ui ./packages/shared-landing-ui

171
docs/CAPACITY_PLANNING.md Normal file
View file

@ -0,0 +1,171 @@
# Kapazitaetsplanung & Hardware-Ressourcen
Stand: 2026-03-28
## Hardware-Uebersicht
### Mac Mini M4 (Produktionsserver)
| Ressource | Wert |
|-----------|------|
| **CPU** | Apple M4, 10 Cores (4P + 6E) |
| **RAM** | 16 GB Unified Memory |
| **GPU** | 10-Core Metal (geteilt mit CPU/RAM) |
| **Storage intern** | 228 GB SSD |
| **Storage extern** | 4 TB SSD (USB-C, ~1 GB/s) |
| **Netzwerk** | Cloudflare Tunnel (kein direktes Port-Forwarding) |
### Windows GPU Server (AI-Workloads)
| Ressource | Wert |
|-----------|------|
| **GPU** | NVIDIA RTX 3090, 24 GB VRAM |
| **Netzwerk** | LAN (192.168.178.11), Tunnel-Subdomains unter gpu-*.mana.how |
| **Services** | Ollama (11434), STT (3020), TTS (3022), Image Gen (3023) |
| **Status** | **Aktiv** — alle AI-Services vom Mac Mini hierher verlagert |
## Aktuelle Auslastung (Mac Mini)
### Container-Uebersicht (61 Container)
| Kategorie | Anzahl | Geschaetzter RAM |
|-----------|--------|------------------|
| Infrastruktur (Postgres, Redis, MinIO, Forgejo) | 7 | ~2.5 GB |
| Core Services (Auth, Credits, User, Subs, Analytics) | 5 | ~0.8 GB |
| Go Services (Gateway, Sync, Search, Notify, Crawler, Media) | 6 | ~0.3 GB |
| Web Apps (SvelteKit, 19 Apps) | 19 | ~3.0 GB |
| Backends (NestJS/Hono) | 3 | ~0.5 GB |
| Matrix (Synapse, Element, Bots) | 4 | ~1.0 GB |
| Monitoring (Grafana, Victoria, Loki, cAdvisor, etc.) | 13 | ~2.0 GB |
| Sonstiges (Watchtower, Landing Builder, LLM) | 4 | ~0.5 GB |
| **Gesamt** | **61** | **~10.6 GB** |
### Native Services
| Service | RAM (idle) | RAM (aktiv) |
|---------|-----------|-------------|
| Ollama (Gemma 3 4B) | ~0 MB (nach 5min entladen) | ~3.3 GB |
| Ollama (Gemma 3 27B) | ~0 MB | ~16 GB (gesamter RAM!) |
| FLUX.2 klein | ~0.5 GB | ~2 GB |
### RAM-Budget
```
Verfuegbar: 16.0 GB
Docker Container: -10.6 GB
macOS Overhead: -1.5 GB
─────────────────────────────
Frei: 3.9 GB ← fuer Ollama, Builds, Peaks
```
**Kritisch:** Bei aktivem Ollama (3.3 GB fuer 4B-Modell) bleiben nur ~0.6 GB fuer Peaks.
Build-Script stoppt deshalb 13 Monitoring-Container (~2 GB) vor dem Bauen.
## Kapazitaetsschaetzung nach Workload-Typ
### Tier 1: Statische/Local-First Apps (wenig Server-Last)
Apps wie Todo, Calendar, Clock, Zitare, Contacts, etc.
| Metrik | Wert | Begruendung |
|--------|------|-------------|
| Gleichzeitige User | **100-200** | Local-first: Reads aus IndexedDB, Server nur fuer Sync |
| Sync-Connections (WebSocket) | **~50 aktiv** | mana-sync (Go) ist sehr effizient, ~10 KB/Connection |
| Bottleneck | Cloudflare Tunnel Latenz (~4s first byte) | Nicht die App selbst |
### Tier 2: API-lastige Apps (Chat, Questions, Context)
| Metrik | Wert | Begruendung |
|--------|------|-------------|
| Gleichzeitige User | **20-50** | Abhaengig von Postgres-Connections (max 20 pro Service) |
| API Requests/sec | **~100-200** | NestJS/Hono koennen mehr, DB ist Limit |
| Bottleneck | PostgreSQL Connections + RAM | |
### Tier 3: AI-Workloads (Ollama, FLUX.2)
| Metrik | Wert | Begruendung |
|--------|------|-------------|
| LLM gleichzeitig | **1** | OLLAMA_NUM_PARALLEL=1, Modell belegt 3-16 GB |
| LLM Durchsatz | **~53 tokens/sec** (4B) | ~260 tokens/sec Prompt Processing |
| Bildgenerierung | **1 gleichzeitig** | ~1.5s pro 1024x1024 Bild |
| Bottleneck | **RAM** (Ollama + Container konkurrieren) | |
### Gesamtschaetzung
| Szenario | Max. gleichzeitige User |
|----------|------------------------|
| Nur Local-First Apps | ~200 |
| Mixed (Local-First + API) | ~50-100 |
| Mit aktiver LLM-Nutzung | ~20-30 |
| Peak (alle Services + LLM + Bildgen) | **~10-20** |
## Bottleneck-Analyse
| Rang | Bottleneck | Auswirkung | Loesung |
|------|-----------|------------|---------|
| 1 | **RAM (16 GB)** | Ollama + Container kaempfen um Speicher | RAM-Upgrade (neuer Mac Mini) oder GPU-Server fuer LLM |
| 2 | **Cloudflare Tunnel Latenz** | ~4s TTFB fuer erste Requests | CDN/Workers fuer statische Assets |
| 3 | **PostgreSQL Connections** | Max 20 pro Service, shared DB | Connection Pooling (PgBouncer) |
| 4 | **Single Server** | Kein Failover, kein horizontales Scaling | Zweiter Mac Mini oder Cloud-Burst |
## Scaling-Roadmap
### Phase 1: Optimierung (0 EUR)
- [x] GPU-Server ueber LAN anbinden → alle AI-Last vom Mac Mini verlagert
- [x] Registrierungslimit implementiert (MAX_DAILY_SIGNUPS, default: unlimitiert)
- [ ] PgBouncer fuer Connection Pooling einrichten
- [ ] Cloudflare Cache Rules fuer statische Assets
- [ ] Registrierungslimit aktivieren (5/Tag) in .env auf Server
### Phase 2: RAM-Upgrade (~700 EUR)
- [ ] Neuer Mac Mini M4 mit 32 GB → doppelte Kapazitaet
- [ ] Oder: gebrauchter Mac Mini M2 als zweiter Server
- [ ] Registrierungslimit auf 15/Tag erhoehen
### Phase 3: Horizontales Scaling (~50 EUR/Monat)
- [ ] Hetzner VPS fuer statische Web-Apps (CAX21: 8 GB, 4 vCPU, ~8 EUR/Monat)
- [ ] Oder: Coolify/Kamal auf dediziertem Server
- [ ] Registrierungslimit auf 50/Tag erhoehen
### Phase 4: Production-Grade (~200 EUR/Monat)
- [ ] Managed PostgreSQL (z.B. Supabase, Neon)
- [ ] CDN fuer alle Web-Apps
- [ ] Multi-Server mit Load Balancing
- [ ] Registrierungslimit entfernen oder auf 500/Tag
## Registrierungslimit ("Organic Growth Gate")
Siehe Implementierung in `services/mana-auth/src/services/signup-limit.ts`.
### Konzept
Pro Tag koennen sich maximal X neue Nutzer registrieren. Das Limit ist konfigurierbar und waechst mit der Hardware.
### Vorteile
1. **Infrastruktur-Schutz:** Hardware waechst mit der Community
2. **Exklusivitaet:** "Heute noch 2 Plaetze frei" erzeugt Nachfrage
3. **Qualitaet:** Fruehe User geben besseres Feedback
4. **Kostenlos:** Kein Over-Provisioning noetig
### Geplante Limits
| Phase | Limit | Kumuliert/30 Tage | Hardware |
|-------|-------|-------------------|----------|
| Start | 5/Tag | ~150 User | Mac Mini 16 GB |
| Phase 2 | 15/Tag | ~450 User | Mac Mini 32 GB |
| Phase 3 | 50/Tag | ~1500 User | Multi-Server |
## Load Testing
Load Tests liegen in `load-tests/`. Siehe `load-tests/README.md` fuer Ausfuehrung.
### Empfohlene Test-Zyklen
1. **Vor jedem Hardware-Upgrade:** Baseline messen
2. **Nach Limit-Erhoehung:** Verifizieren dass Hardware haelt
3. **Monatlich:** Regression erkennen

58
load-tests/README.md Normal file
View file

@ -0,0 +1,58 @@
# Load Tests
k6-basierte Load Tests fuer die Mana-Infrastruktur.
## Setup
```bash
# k6 installieren (macOS)
brew install k6
# WebSocket-Extension (fuer Sync-Tests)
# k6 hat WebSocket-Support eingebaut
```
## Tests ausfuehren
```bash
# Gegen lokale Umgebung
k6 run load-tests/web-apps.js
k6 run load-tests/auth-api.js
k6 run load-tests/sync-websocket.js
k6 run load-tests/llm-ollama.js
# Gegen Produktion (vorsichtig!)
k6 run -e BASE_URL=https://mana.how load-tests/web-apps.js
# Mit mehr/weniger Last
k6 run --vus 100 --duration 5m load-tests/web-apps.js
# JSON-Output fuer Grafana
k6 run --out json=results.json load-tests/web-apps.js
```
## Test-Szenarien
| Script | Ziel | Default VUs | Dauer |
|--------|------|-------------|-------|
| `web-apps.js` | SvelteKit Frontends (HTML-Responses) | 10→50→10 | 5 min |
| `auth-api.js` | Login, Register, Token Validation | 5→20→5 | 4 min |
| `sync-websocket.js` | mana-sync WebSocket Connections | 10→30→10 | 5 min |
| `llm-ollama.js` | Ollama Chat Completions | 1→3→1 | 3 min |
## Metriken interpretieren
| Metrik | Gut | Akzeptabel | Schlecht |
|--------|-----|-----------|---------|
| http_req_duration (p95) | < 200ms | < 1s | > 2s |
| http_req_failed | 0% | < 1% | > 5% |
| ws_connecting (p95) | < 100ms | < 500ms | > 1s |
| iterations | Steigend | Stabil | Fallend |
## Monitoring waehrend Tests
Grafana-Dashboard auf http://localhost:8080 (oder https://grafana.mana.how) beobachten:
- Container CPU/RAM (cAdvisor)
- PostgreSQL Connections
- Redis Commands/sec
- Netzwerk-Throughput

81
load-tests/auth-api.js Normal file
View file

@ -0,0 +1,81 @@
/* eslint-disable no-undef */
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Counter } from 'k6/metrics';
const errorRate = new Rate('errors');
const signupBlocked = new Counter('signup_blocked');
const AUTH_URL = __ENV.AUTH_URL || 'http://localhost:3001';
export const options = {
stages: [
{ duration: '30s', target: 5 },
{ duration: '2m', target: 20 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<3000'],
errors: ['rate<0.10'],
},
};
// Generate unique test emails
function testEmail(vuId, iter) {
return `loadtest_vu${vuId}_${iter}_${Date.now()}@test.invalid`;
}
export default function () {
// Weighted random: 70% health, 20% login attempts, 10% register
const roll = Math.random();
if (roll < 0.7) {
// Health check — lightweight, tests baseline
group('health', () => {
const res = http.get(`${AUTH_URL}/health`);
const ok = check(res, {
'health 200': (r) => r.status === 200,
});
errorRate.add(!ok);
});
} else if (roll < 0.9) {
// Login attempt with invalid credentials — tests lockout + DB
group('login', () => {
const res = http.post(
`${AUTH_URL}/api/v1/auth/login`,
JSON.stringify({
email: 'loadtest@nonexistent.invalid',
password: 'wrongpassword',
}),
{ headers: { 'Content-Type': 'application/json' } }
);
const ok = check(res, {
'login returns 401 or 429': (r) => r.status === 401 || r.status === 429,
});
errorRate.add(!ok);
});
} else {
// Registration — tests signup limit
group('register', () => {
const email = testEmail(__VU, __ITER);
const res = http.post(
`${AUTH_URL}/api/v1/auth/register`,
JSON.stringify({
email: email,
password: 'TestPassword123!',
name: `Load Test ${__VU}`,
}),
{ headers: { 'Content-Type': 'application/json' } }
);
const ok = check(res, {
'register returns 200 or 429': (r) => r.status === 200 || r.status === 429,
});
if (res.status === 429) {
signupBlocked.add(1);
}
errorRate.add(!ok);
});
}
sleep(Math.random() * 1.5 + 0.5);
}

82
load-tests/llm-ollama.js Normal file
View file

@ -0,0 +1,82 @@
/* eslint-disable no-undef, @typescript-eslint/no-unused-vars */
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
const errorRate = new Rate('errors');
const tokensPerSec = new Trend('tokens_per_second', true);
const totalTokens = new Counter('total_tokens_generated');
const OLLAMA_URL = __ENV.OLLAMA_URL || 'http://localhost:11434';
const MODEL = __ENV.MODEL || 'gemma3:4b';
export const options = {
// LLM is single-threaded effectively — test with few VUs
stages: [
{ duration: '30s', target: 1 },
{ duration: '2m', target: 3 },
{ duration: '30s', target: 1 },
],
thresholds: {
http_req_duration: ['p(95)<30000'], // LLM responses can be slow
errors: ['rate<0.10'],
},
};
const prompts = [
'Was ist die Hauptstadt von Deutschland? Antworte in einem Satz.',
'Erklaere Photosynthese in 2 Saetzen.',
'Schreibe ein kurzes Haiku ueber Programmierung.',
'Was ist der Unterschied zwischen TCP und UDP? Kurz.',
'Nenne 3 Vorteile von Self-Hosting.',
];
export default function () {
const prompt = prompts[Math.floor(Math.random() * prompts.length)];
// Non-streaming request for easier metrics
const res = http.post(
`${OLLAMA_URL}/api/generate`,
JSON.stringify({
model: MODEL,
prompt: prompt,
stream: false,
options: {
num_predict: 100, // Cap tokens to keep tests fast
},
}),
{
headers: { 'Content-Type': 'application/json' },
timeout: '60s',
}
);
const ok = check(res, {
'status is 200': (r) => r.status === 200,
'has response text': (r) => {
try {
const body = JSON.parse(r.body);
return body.response && body.response.length > 0;
} catch {
return false;
}
},
});
if (ok && res.status === 200) {
try {
const body = JSON.parse(res.body);
// Ollama returns eval_count and eval_duration
if (body.eval_count && body.eval_duration) {
const tps = body.eval_count / (body.eval_duration / 1e9);
tokensPerSec.add(tps);
totalTokens.add(body.eval_count);
}
} catch (_) {}
}
errorRate.add(!ok);
// Longer pause between LLM requests — realistic usage
sleep(Math.random() * 5 + 3);
}

View file

@ -0,0 +1,84 @@
/* eslint-disable no-undef, no-console, @typescript-eslint/no-unused-vars */
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Rate, Counter, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const messagesReceived = new Counter('ws_messages_received');
const messagesSent = new Counter('ws_messages_sent');
const connectTime = new Trend('ws_connect_time', true);
const SYNC_URL = __ENV.SYNC_URL || 'ws://localhost:3050';
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '3m', target: 30 },
{ duration: '30s', target: 0 },
],
thresholds: {
errors: ['rate<0.10'],
ws_connect_time: ['p(95)<1000'],
},
};
export default function () {
const url = `${SYNC_URL}/ws`;
const startTime = Date.now();
const res = ws.connect(url, {}, function (socket) {
const connected = Date.now() - startTime;
connectTime.add(connected);
socket.on('open', () => {
// Send a sync handshake (collection subscription)
const handshake = JSON.stringify({
type: 'subscribe',
collections: ['tasks', 'events', 'contacts'],
userId: `loadtest-vu-${__VU}`,
lastSyncTimestamp: new Date(Date.now() - 60000).toISOString(),
});
socket.send(handshake);
messagesSent.add(1);
});
socket.on('message', (data) => {
messagesReceived.add(1);
// Parse and validate sync messages
try {
const msg = JSON.parse(data);
check(msg, {
'has type field': (m) => m.type !== undefined,
});
} catch (_) {
// Binary or non-JSON message
}
});
socket.on('error', (e) => {
errorRate.add(true);
console.error(`WS error VU ${__VU}: ${e.error()}`);
});
// Keep connection alive for 10-30 seconds (simulates real user session)
const sessionDuration = Math.random() * 20 + 10;
// Send periodic sync pings
const pingInterval = setInterval(() => {
socket.send(JSON.stringify({ type: 'ping' }));
messagesSent.add(1);
}, 5000);
sleep(sessionDuration);
clearInterval(pingInterval);
socket.close();
});
const ok = check(res, {
'WS connection status is 101': (r) => r && r.status === 101,
});
errorRate.add(!ok);
sleep(Math.random() * 2 + 1);
}

72
load-tests/web-apps.js Normal file
View file

@ -0,0 +1,72 @@
/* eslint-disable no-undef */
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const appLatency = new Trend('app_latency', true);
const BASE = __ENV.BASE_URL || 'http://localhost';
// All deployed SvelteKit web apps with their ports
const apps = [
{ name: 'dashboard', url: `${BASE}:5173` },
{ name: 'chat', url: `${BASE}:3000` },
{ name: 'todo', url: `${BASE}:5188` },
{ name: 'zitare', url: `${BASE}:5185` },
{ name: 'calendar', url: `${BASE}:5186` },
{ name: 'clock', url: `${BASE}:5187` },
{ name: 'contacts', url: `${BASE}:5176` },
{ name: 'storage', url: `${BASE}:5178` },
{ name: 'presi', url: `${BASE}:5180` },
{ name: 'manadeck', url: `${BASE}:5181` },
{ name: 'nutriphi', url: `${BASE}:5182` },
{ name: 'skilltree', url: `${BASE}:5183` },
{ name: 'photos', url: `${BASE}:5184` },
{ name: 'mukke', url: `${BASE}:5189` },
{ name: 'citycorners', url: `${BASE}:5190` },
{ name: 'picture', url: `${BASE}:5174` },
{ name: 'inventar', url: `${BASE}:5191` },
];
// When testing against production, use subdomains
const prodApps = [
{ name: 'dashboard', url: 'https://mana.how' },
{ name: 'chat', url: 'https://chat.mana.how' },
{ name: 'todo', url: 'https://todo.mana.how' },
{ name: 'calendar', url: 'https://calendar.mana.how' },
{ name: 'clock', url: 'https://clock.mana.how' },
];
export const options = {
stages: [
{ duration: '30s', target: 10 }, // Ramp up
{ duration: '3m', target: 50 }, // Hold at 50 VUs
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<2000'], // 95% under 2s
errors: ['rate<0.05'], // <5% errors
},
};
export default function () {
const targets = __ENV.BASE_URL?.startsWith('https') ? prodApps : apps;
const app = targets[Math.floor(Math.random() * targets.length)];
const res = http.get(app.url, {
tags: { app: app.name },
timeout: '10s',
});
const success = check(res, {
'status is 200': (r) => r.status === 200,
'response has body': (r) => r.body && r.body.length > 0,
'response time < 2s': (r) => r.timings.duration < 2000,
});
errorRate.add(!success);
appLatency.add(res.timings.duration, { app: app.name });
sleep(Math.random() * 2 + 0.5); // 0.5-2.5s between requests
}

View file

@ -15,6 +15,7 @@ import { jwtAuth } from './middleware/jwt-auth';
import { serviceAuth } from './middleware/service-auth';
import { initializeEmail } from './email/send';
import { SecurityEventsService, AccountLockoutService } from './services/security';
import { SignupLimitService } from './services/signup-limit';
import { ApiKeysService } from './services/api-keys';
import { createAuthRoutes } from './routes/auth';
import { createGuildRoutes } from './routes/guilds';
@ -31,6 +32,7 @@ const auth = createBetterAuth(config.databaseUrl);
initializeEmail(config.smtp);
const security = new SecurityEventsService(db);
const lockout = new AccountLockoutService(db);
const signupLimit = new SignupLimitService(db);
const apiKeysService = new ApiKeysService(db);
// ─── App ────────────────────────────────────────────────────
@ -61,7 +63,7 @@ app.get('/.well-known/openid-configuration', async (c) => auth.handler(c.req.raw
// ─── Custom Auth Endpoints ──────────────────────────────────
app.route('/api/v1/auth', createAuthRoutes(auth, config, security, lockout));
app.route('/api/v1/auth', createAuthRoutes(auth, config, security, lockout, signupLimit));
// ─── Guilds ─────────────────────────────────────────────────

View file

@ -9,6 +9,7 @@ import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
import type { BetterAuthInstance } from '../auth/better-auth.config';
import type { SecurityEventsService, AccountLockoutService } from '../services/security';
import type { SignupLimitService } from '../services/signup-limit';
import type { Config } from '../config';
import { sourceAppStore, passwordResetRedirectStore } from '../auth/stores';
@ -16,15 +17,37 @@ export function createAuthRoutes(
auth: BetterAuthInstance,
config: Config,
security: SecurityEventsService,
lockout: AccountLockoutService
lockout: AccountLockoutService,
signupLimit: SignupLimitService
) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
// ─── Registration ────────────────────────────────────────
// ─── Signup Status (public) ─────────────────────────────
app.get('/signup-status', async (c) => {
const status = await signupLimit.getStatus();
return c.json(status);
});
app.post('/register', async (c) => {
const body = await c.req.json();
// Check daily signup limit
const limitCheck = await signupLimit.checkLimit();
if (!limitCheck.allowed) {
return c.json(
{
error: 'Registration limit reached',
message: 'Das tägliche Registrierungslimit ist erreicht. Versuche es morgen wieder.',
spotsRemaining: 0,
resetsAt: limitCheck.resetsAt,
},
429
);
}
// Store source app URL for email verification redirect
if (body.sourceAppUrl && body.email) {
sourceAppStore.set(body.email, body.sourceAppUrl);

View file

@ -0,0 +1,93 @@
/**
* Signup Limit Daily registration cap ("Organic Growth Gate")
*
* Limits new registrations per day to protect hardware and
* enable organic growth. Uses PostgreSQL security_events table
* (no Redis dependency needed).
*
* Configure via MAX_DAILY_SIGNUPS env var (default: 0 = unlimited).
*/
import { sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
export class SignupLimitService {
private maxDaily: number;
constructor(private db: Database) {
this.maxDaily = parseInt(process.env.MAX_DAILY_SIGNUPS || '0', 10);
}
/** Check if registration is allowed right now */
async checkLimit(): Promise<{
allowed: boolean;
current: number;
limit: number;
resetsAt: string;
}> {
// 0 = unlimited (feature disabled)
if (this.maxDaily <= 0) {
return { allowed: true, current: 0, limit: 0, resetsAt: '' };
}
const todayCount = await this.getTodayCount();
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
return {
allowed: todayCount < this.maxDaily,
current: todayCount,
limit: this.maxDaily,
resetsAt: midnight.toISOString(),
};
}
/** Count registrations today (UTC) */
private async getTodayCount(): Promise<number> {
try {
const result = await this.db.execute(
sql`SELECT COUNT(*) as count
FROM auth.security_events
WHERE event_type = 'REGISTER'
AND created_at >= CURRENT_DATE
AND created_at < CURRENT_DATE + INTERVAL '1 day'`
);
const row = (result as any)[0];
return row ? Number(row.count) : 0;
} catch {
// On error, allow registration (fail open)
return 0;
}
}
/** Public status for the signup page */
async getStatus(): Promise<{
registrationOpen: boolean;
spotsRemaining: number | null;
totalToday: number;
limit: number;
resetsAt: string;
}> {
if (this.maxDaily <= 0) {
return {
registrationOpen: true,
spotsRemaining: null,
totalToday: 0,
limit: 0,
resetsAt: '',
};
}
const todayCount = await this.getTodayCount();
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
return {
registrationOpen: todayCount < this.maxDaily,
spotsRemaining: Math.max(0, this.maxDaily - todayCount),
totalToday: todayCount,
limit: this.maxDaily,
resetsAt: midnight.toISOString(),
};
}
}