369 lines
15 KiB
Markdown
369 lines
15 KiB
Markdown
# 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=<hex>`,
|
||
`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 = <verifyToken>` 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 |
|