Files
QR-master/docs/PLAN_API_WEBHOOKS_ENTERPRISE.md
2026-07-12 12:32:30 +02:00

15 KiB
Raw Blame History

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 (≈ 12 Tage)

1.1 Datenbank (manuell per SQL, gemäß DB-Policy — kein Migrate)

ALTER TYPE "Plan" ADD VALUE 'ENTERPRISE';

Danach: prisma/schema.prismaenum 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:

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 (≈ 34 Tage) · ab PRO

2.1 Datenbank (raw 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):

{
  "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 (≈ 46 Tage) · ab BUSINESS

3.1 Datenbank (raw 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 (≈ 12 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 (≈ 58 Tage) · ENTERPRISE

Der technisch riskanteste Teil — TLS für fremde Domains ist Infra-Arbeit.

5.1 Datenbank (raw 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.

{
  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 12 Tage
2 Webhooks 34 Tage Phase 1 (Gating)
3 Public API 46 Tage Phase 1; Payload-Format aus Phase 2 wiederverwenden
4 White-Label 12 Tage Phase 1
5 Custom Domains 58 Tage Phase 1; Infra-Klärung (Proxy/DNS) vorab
Gesamt ≈ 1422 Arbeitstage Launch von „Enterprise light" nach Phase 13 möglich