From c352cb75b5962114cd97ad00cdba4a71514d7fdf Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Sun, 12 Jul 2026 12:32:30 +0200 Subject: [PATCH] Bild Carousel V3 --- docs/PLAN_API_WEBHOOKS_ENTERPRISE.md | 368 +++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/PLAN_API_WEBHOOKS_ENTERPRISE.md diff --git a/docs/PLAN_API_WEBHOOKS_ENTERPRISE.md b/docs/PLAN_API_WEBHOOKS_ENTERPRISE.md new file mode 100644 index 0000000..6a16bc7 --- /dev/null +++ b/docs/PLAN_API_WEBHOOKS_ENTERPRISE.md @@ -0,0 +1,368 @@ +# Plan: Public API, Webhooks, Enterprise-Plan, White-Label & Custom Domains + +Stand: 2026-07-11 · Autor: Claude (Analyse der Codebase auf Commit `d542f84`) + +**Feature-Gating-Übersicht:** + +| Feature | FREE | PRO | BUSINESS | ENTERPRISE | +|---|---|---|---|---| +| Webhooks | – | ✅ (3 Endpoints) | ✅ (10) | ✅ (unbegrenzt) | +| Public REST API | – | – | ✅ (1.000 req/h) | ✅ (10.000 req/h) | +| White-Label (Branding aus) | – | – | – | ✅ | +| Custom Domain (CNAME) | – | – | – | ✅ (bis 3 Domains) | +| Dynamische QRs | 3 | 50 | 500 | unbegrenzt | +| Preis | 0 € | 9 €/M | 29 €/M | 149 €/M · 1.490 €/J | + +Empfohlene Reihenfolge: **Phase 1 → 2 → 3** ist launchbar als „Enterprise light" +(CNAME als „coming soon"), Phase 4 + 5 danach. + +--- + +## Phase 1 — Enterprise-Plan (≈ 1–2 Tage) + +### 1.1 Datenbank (manuell per SQL, gemäß DB-Policy — kein Migrate) + +```sql +ALTER TYPE "Plan" ADD VALUE 'ENTERPRISE'; +``` + +Danach: `prisma/schema.prisma` → `enum Plan { FREE PRO BUSINESS ENTERPRISE }`, +dann `npx prisma generate`. + +### 1.2 Stripe + +- Im Stripe-Dashboard zwei Prices anlegen: **149 €/Monat**, **1.490 €/Jahr** + (Produkt „QR Master Enterprise"). +- Neue Env-Vars: `STRIPE_PRICE_ID_ENTERPRISE_MONTHLY`, `STRIPE_PRICE_ID_ENTERPRISE_YEARLY` + (auch in `env.example` ergänzen). + +### 1.3 Code-Änderungen + +| Datei | Änderung | +|---|---| +| `src/lib/plans.ts` | `ENTERPRISE_DYNAMIC_QR_LIMIT` sauber definieren (Wert `-1` = unbegrenzt statt 99999, oder 99999 belassen — Konsistenz mit `staticQRCodes: -1` prüfen) | +| `src/lib/stripe.ts` | `STRIPE_PLANS.ENTERPRISE` mit Features-Liste + beiden priceIds; `getPlanFromStripePriceId()` um ENTERPRISE-Zweig erweitern (VOR dem BUSINESS-Check einfügen) | +| Pricing-Page (`(marketing)`) | 4. Spalte Enterprise; Features: „Alles aus Business", „Public API 10k req/h", „Unbegrenzte Webhooks", „White-Label", „Custom Domain (CNAME)", „Priority Support & SLA", „AV-Vertrag (DPA)" | +| Checkout/Portal-Routen (`api/stripe/*`) | Prüfen, ob Plan-Namen irgendwo hart kodiert sind (`grep -r "BUSINESS" src/app/(main)/api/stripe`) | +| Webhook-Handler `api/stripe/webhook/route.ts` | Sollte über `getPlanFromStripePriceId()` automatisch funktionieren — verifizieren | + +### 1.4 Zentrale Gating-Helper (Grundlage für alle weiteren Phasen) + +Neue Datei `src/lib/entitlements.ts`: + +```ts +export const ENTITLEMENTS = { + FREE: { webhooks: 0, api: false, apiRatePerHour: 0, whiteLabel: false, customDomains: 0 }, + PRO: { webhooks: 3, api: false, apiRatePerHour: 0, whiteLabel: false, customDomains: 0 }, + BUSINESS: { webhooks: 10, api: true, apiRatePerHour: 1000, whiteLabel: false, customDomains: 0 }, + ENTERPRISE: { webhooks: -1, api: true, apiRatePerHour: 10000, whiteLabel: true, customDomains: 3 }, +} as const; +``` + +Alle Feature-Checks laufen NUR über diese Datei — nie `plan === 'PRO'` inline. + +### 1.5 Verifikation + +- Test-Checkout mit Stripe-Testkarte (4242…) auf Enterprise monatlich + jährlich. +- Downgrade/Upgrade über Customer Portal → `plan`-Feld in DB prüfen. + +--- + +## Phase 2 — Webhooks (≈ 3–4 Tage) · ab PRO + +### 2.1 Datenbank (raw SQL) + +```sql +CREATE TABLE "WebhookEndpoint" ( + "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, + "url" TEXT NOT NULL, + "secret" TEXT NOT NULL, -- whsec_..., wird nur 1x angezeigt + "events" TEXT[] NOT NULL DEFAULT '{qr.scanned}', + "active" BOOLEAN NOT NULL DEFAULT true, + "failCount" INTEGER NOT NULL DEFAULT 0, -- für Auto-Disable + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX "WebhookEndpoint_userId_idx" ON "WebhookEndpoint"("userId"); + +CREATE TABLE "WebhookDelivery" ( + "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + "endpointId" TEXT NOT NULL REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE, + "event" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "statusCode" INTEGER, + "success" BOOLEAN NOT NULL DEFAULT false, + "attempts" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" + ON "WebhookDelivery"("endpointId", "createdAt"); +``` + +Schema.prisma nachziehen, `prisma generate`. Deliveries nach 30 Tagen löschen +(einfacher Cleanup beim Dispatch oder Cron). + +### 2.2 Dispatch-Modul `src/lib/webhooks.ts` + +- `dispatchEvent(userId, event, data)`: + 1. Aktive Endpoints des Users laden, die `event` abonniert haben. + 2. Payload bauen (s. u.), HMAC-SHA256 über den Raw-Body mit `secret`. + 3. POST mit Headers `X-QRMaster-Signature: sha256=`, + `X-QRMaster-Event`, `X-QRMaster-Delivery-Id`; Timeout 5 s. + 4. Retry inline: max. 3 Versuche (0 s / 10 s / 60 s Backoff) — bewusst ohne + Job-Queue, fire-and-forget wie `trackScan`. + 5. `WebhookDelivery` loggen; bei Erfolg `failCount = 0`, bei Fehlschlag + `failCount++`; ab 20 Fehlschlägen in Folge `active = false` + E-Mail an + User (via `src/lib/email.ts`). +- **SSRF-Schutz (wichtig!)**: URL-Validierung beim Anlegen UND vor jedem + Request — nur `https://`, DNS auflösen und private/interne IP-Ranges + blocken (127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1). + +Payload-Format (stabil halten, das ist API-Vertrag): + +```json +{ + "id": "evt_...", + "event": "qr.scanned", + "createdAt": "2026-07-11T12:00:00Z", + "data": { + "qrId": "...", "slug": "...", "name": "...", + "scan": { "ts": "...", "country": "DE", "city": "Berlin", + "device": "mobile", "os": "iOS", "isUnique": true } + } +} +``` + +Kein `ipHash` im Payload (Datenschutz). + +### 2.3 Event-Quellen einhängen + +| Event | Ort | +|---|---| +| `qr.scanned` | `src/app/(main)/r/[slug]/route.ts` → am Ende von `trackScan()` (dort liegen bereits alle Geo/Device-Daten vor) | +| `qr.created` / `qr.updated` / `qr.deleted` | `api/qrs/route.ts` + `api/qrs/[id]/route.ts` nach erfolgreichem DB-Write | + +DNT-Verhalten spiegeln: wenn `trackScan` wegen DNT keinen Scan schreibt, +auch kein `qr.scanned`-Event feuern. + +### 2.4 Management-API (Session-Auth, CSRF wie bestehende Routen) + +- `GET/POST /api/webhooks` — Liste / Anlegen (Zod-Schema in `validationSchemas.ts`; + Limit aus `ENTITLEMENTS[plan].webhooks` prüfen; Secret `whsec_` + 32 random + Bytes generieren, im Response einmalig zurückgeben) +- `PATCH/DELETE /api/webhooks/[id]` — aktivieren/deaktivieren/URL/Events ändern, löschen +- `POST /api/webhooks/[id]/test` — Test-Event `webhook.test` senden, Response-Status zurückgeben +- `GET /api/webhooks/[id]/deliveries` — letzte 50 Deliveries + +### 2.5 UI + +Neuer Tab/Bereich in `src/app/(main)/(app)/settings/page.tsx` (oder eigene +Seite `settings/webhooks`): Endpoint-Liste mit Status-Badge, Anlegen-Dialog +(URL + Event-Checkboxen), Secret-einmal-anzeigen-Modal (Copy-Button), +„Test senden"-Button, Delivery-Log aufklappbar. Für FREE: Upsell-Karte. + +### 2.6 Doku + Verifikation + +- Doku-Seite `/docs/webhooks` (Signatur-Verifikations-Beispiel in Node/PHP). +- Test end-to-end mit webhook.site + lokalem Scan; Signatur nachrechnen; + Retry testen (Endpoint der 500 liefert); SSRF-Check (`http://localhost` muss abgelehnt werden). + +--- + +## Phase 3 — Public REST API (≈ 4–6 Tage) · ab BUSINESS + +### 3.1 Datenbank (raw SQL) + +```sql +CREATE TABLE "ApiKey" ( + "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, + "name" TEXT NOT NULL, + "keyHash" TEXT NOT NULL UNIQUE, -- SHA-256 des vollen Keys + "keyPrefix" TEXT NOT NULL, -- "qrm_live_a1b2c3" für UI-Anzeige + "lastUsedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); +``` + +Key-Format: `qrm_live_` + 32 random Bytes base62. Lookup über SHA-256-Hash +(deterministisch → `keyHash` direkt per `findUnique`, kein bcrypt nötig, +Keys haben genug Entropie). + +### 3.2 Auth- & Rate-Limit-Middleware `src/lib/apiAuth.ts` + +- `authenticateApiKey(request)`: `Authorization: Bearer qrm_live_...` → + Hash → Key + User laden → revoked prüfen → `ENTITLEMENTS[plan].api` + prüfen → `lastUsedAt` throttled updaten (max. 1×/min) → + `{ userId, plan }` zurückgeben oder 401/403. +- Rate-Limiting **Redis-basiert** (nicht das in-memory `rateLimit.ts`): + neuer `src/lib/redisRateLimit.ts` mit Sliding Window + (`INCR` + `EXPIRE` pro `apikey:{id}:{hourBucket}` reicht). + Limits aus `ENTITLEMENTS`. Fallback ohne Redis: in-memory mit Warnung. + Standard-Header `X-RateLimit-*` + `Retry-After` bei 429. + +### 3.3 Service-Layer-Refactoring (der eigentliche Aufwand) + +Kernlogik aus den Session-Routen in `src/lib/services/qrService.ts` +extrahieren, damit Session-Routen und v1-Routen dieselbe Logik nutzen: +`listQrs(userId)`, `createQr(userId, plan, input)` (inkl. Plan-Limit-Check), +`updateQr`, `deleteQr`, `getAnalyticsSummary(userId, qrId, range)`. +Bestehende Routen auf den Service umstellen — **keine Logik duplizieren**. + +### 3.4 Endpoints unter `src/app/(main)/api/v1/` + +Kein CSRF (Bearer-Auth), JSON-Fehlerformat `{ "error": { "code", "message" } }`: + +| Endpoint | Beschreibung | +|---|---| +| `GET /api/v1/qr-codes` | Liste, Pagination `?limit=&cursor=` | +| `POST /api/v1/qr-codes` | Anlegen (gleiche Zod-Schemas wie intern) | +| `GET/PATCH/DELETE /api/v1/qr-codes/{id}` | Einzeloperationen | +| `GET /api/v1/qr-codes/{id}/analytics?from=&to=` | Scans, Unique, Country/Device-Breakdown | +| `GET /api/v1/qr-codes/{id}/image?format=png\|svg&size=` | QR-Bild (nutzt `src/lib/qr.ts`) | +| `GET /api/v1/me` | Plan, Limits, Verbrauch (guter Smoke-Test-Endpoint) | + +Versionierung im Pfad (`/v1/`) — Breaking Changes später nur via `/v2/`. + +### 3.5 Key-Verwaltung + +- Session-Routen `GET/POST /api/api-keys`, `DELETE /api/api-keys/[id]` (= revoke). +- UI in Settings: Key-Liste (Prefix, lastUsed), „Create key" mit + Einmal-Anzeige-Modal, Revoke mit Confirm. Max. 5 Keys/User. +- Gating: FREE/PRO sehen Upsell auf Business. + +### 3.6 Doku (Pflicht, sonst wertlos) + +Marketing-Seite `/docs/api`: Auth, Rate Limits, alle Endpoints mit +curl-Beispielen, Fehlercodes, Webhook-Sektion verlinken. OpenAPI-Spec +(`public/openapi.json`) optional als Follow-up. + +### 3.7 Verifikation + +curl-Suite gegen lokalen Dev-Server: 401 ohne Key, 403 mit PRO-Key, +CRUD-Roundtrip mit Business-Key, 429 nach Limit, revoked Key → 401. + +--- + +## Phase 4 — White-Label (≈ 1–2 Tage) · ENTERPRISE + +### 4.1 Scope + +„Powered by QR Master"-Branding entfernen auf allen Seiten, die Endkunden +beim Scannen sehen: vCard-Display, Text-Display, Coupon-, Feedback-Seiten +(`grep -ri "qr master\|qrmaster" src/app` über die Display-Seiten, um alle +Stellen zu finden). + +### 4.2 Umsetzung + +- User-Felder (raw SQL): `whiteLabelEnabled BOOLEAN DEFAULT false`, + optional `brandLogoUrl TEXT`, `brandColor TEXT`. +- Display-Seiten laden den QR-Owner sowieso → Branding-Block konditional + auf `ENTITLEMENTS[plan].whiteLabel && whiteLabelEnabled`. +- Settings-Toggle (nur Enterprise sichtbar), optional Logo-Upload via + bestehendem S3-Setup. +- Wichtig: Beim Downgrade (Stripe-Webhook) `whiteLabelEnabled` NICHT löschen, + aber Anzeige-Check läuft immer über den aktuellen Plan → verhält sich korrekt. + +--- + +## Phase 5 — Custom Domains / CNAME (≈ 5–8 Tage) · ENTERPRISE + +Der technisch riskanteste Teil — TLS für fremde Domains ist Infra-Arbeit. + +### 5.1 Datenbank (raw SQL) + +```sql +CREATE TABLE "CustomDomain" ( + "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, + "domain" TEXT NOT NULL UNIQUE, -- z. B. qr.kunde.de (lowercase) + "verifyToken" TEXT NOT NULL, -- für TXT-Record + "verifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +### 5.2 Verifizierungs-Flow + +1. User trägt Domain ein → App zeigt zwei DNS-Records: + `TXT _qrmaster-verify.qr.kunde.de = ` und + `CNAME qr.kunde.de → qrmaster.net`. +2. `POST /api/domains/[id]/verify` prüft beides via Node `dns.promises.resolveTxt/resolveCname`. +3. Erst nach Verifizierung wird die Domain aktiv (verhindert Domain-Hijacking). + +### 5.3 Request-Routing in Next.js + +- `src/middleware.ts` (bzw. erweitern, falls vorhanden): wenn `Host` ≠ + qrmaster.net/localhost → rewrite auf `/r/...` Namespace; auf + Fremd-Domains NUR `/r/[slug]` bedienen, alles andere → 404 + (keine Login-/Marketing-Seiten unter Kundendomains, SEO/Sicherheit). +- `/r/[slug]`-Route: bei Fremd-Host prüfen, dass der Slug-Owner die + verifizierte Domain besitzt — sonst 404 (verhindert, dass fremde QRs + über eine Kundendomain laufen). +- Redis-Cache für Domain-Lookups (Hostname → userId, TTL 5 min). + +### 5.4 TLS — Caddy als Reverse Proxy (Docker-Deployment) + +`docker-compose.yml`: Caddy-Service vor `web`, Ports 80/443, Volume für Zertifikate. + +```caddyfile +{ + on_demand_tls { + ask http://web:3050/api/domains/check # gibt 200 nur für verifizierte Domains + } +} +https:// { + tls { on_demand } + reverse_proxy web:3050 +} +qrmaster.net, www.qrmaster.net { + reverse_proxy web:3050 +} +``` + +- Neuer interner Endpoint `GET /api/domains/check?domain=` → 200 wenn + verifiziert, sonst 403 (schützt vor Zertifikats-Missbrauch/Let's-Encrypt-Rate-Limits). +- Falls qrmaster.net aktuell hinter Cloudflare/anderem Proxy läuft: + vorher klären, wie Port 443 heute terminiert wird — Caddy ersetzt bzw. + ergänzt das bestehende Setup. **Vor Umbau: aktuelles docker-compose.yml + und DNS-Setup prüfen** (größte Unbekannte in diesem Plan). + +### 5.5 UI + +Settings-Bereich „Custom Domains" (nur Enterprise): Domain hinzufügen, +DNS-Anleitung mit Copy-Buttons, Verify-Button mit Status, Limit 3 Domains. +QR-Detail/Generator: Kurz-URL-Anzeige nutzt die Custom Domain, wenn vorhanden. + +### 5.6 Verifikation + +Lokal mit `/etc/hosts`-Eintrag + Caddy testen; auf dem Server mit einer +eigenen Test-Domain (z. B. `qr.timo-test.de`) den kompletten Flow durchspielen, +bevor es an Kunden geht. + +--- + +## Querschnittsthemen + +- **Downgrade-Verhalten**: Bei Plan-Downgrade (Stripe-Webhook) Webhooks über + Limit deaktivieren (nicht löschen), API-Keys behalten aber `api: false` + greift beim Auth-Check, Custom Domains deaktivieren. Ein zentraler + `enforceEntitlements(userId)` im Stripe-Webhook-Handler. +- **Abuse**: API-Key-Erstellung rate-limiten; Webhook-URLs SSRF-geprüft; + Delivery-Payloads klein halten. +- **Doku/SEO**: `/docs/api` + `/docs/webhooks` sind auch Marketing-Seiten + („QR Code API") — lohnt eigene SEO-Behandlung wie die Growth-Pages. + +## Zeitplan gesamt + +| Phase | Aufwand | Abhängig von | +|---|---|---| +| 1 Enterprise-Plan + Entitlements | 1–2 Tage | — | +| 2 Webhooks | 3–4 Tage | Phase 1 (Gating) | +| 3 Public API | 4–6 Tage | Phase 1; Payload-Format aus Phase 2 wiederverwenden | +| 4 White-Label | 1–2 Tage | Phase 1 | +| 5 Custom Domains | 5–8 Tage | Phase 1; Infra-Klärung (Proxy/DNS) vorab | +| **Gesamt** | **≈ 14–22 Arbeitstage** | Launch von „Enterprise light" nach Phase 1–3 möglich |