# Plan: Dashboard auf app.qrmaster.net Stand: 2026-08-12 · Ziel: die eingeloggte App liegt auf `app.qrmaster.net`, Marketing/SEO bleibt auf `www.qrmaster.net`. ## Status | Schritt | Stand | |---|---| | B1 Cookie-Domain | committed + gepusht (`35ea8cc`) | | B2–B7 | Code fertig, typecheck + Production-Build grün, **noch nicht deployt** | | A4 Google Console | erledigt (beide Redirect-URIs eingetragen) | | A1 DNS, A2 Caddy, A3 .env, A6 Deploy | offen bei Timo | Deploy-Reihenfolge unverändert: B1 zuerst allein live und einen Tag beobachten, dann B2–B7. Neu gegenüber dem ursprünglichen Plan: `src/lib/hosts.ts` ist die einzige Quelle der Wahrheit für die Host-Grenze (`APP_PATH_PREFIXES`, `isAppPath`, `wwwUrl`, `appUrl`, `urlForPath`). Middleware, Stripe-Rückkehr-URLs und E-Mail-Links lesen alle daraus, damit sie nicht auseinanderdriften. ## Zielarchitektur **Ein Docker-Image, ein Container, zwei Hostnames.** Caddy routet `www.qrmaster.net` und `app.qrmaster.net` auf denselben Upstream. Die Middleware macht Host-basiertes Routing. **Wichtig: keine Datei zieht um.** Die Next-App serviert auf beiden Hosts weiterhin alle Routen. `src/middleware.ts` entscheidet pro Host, welcher Pfad ausgeliefert wird, und 301t den Rest auf den jeweils anderen Host. Damit bleiben alle relativen Links (`router.push('/dashboard')`, ``) unverändert korrekt, weil sie innerhalb desselben Hosts aufgelöst werden. ``` qrmaster.net --301--> www.qrmaster.net (bleibt wie heute) www.qrmaster.net -> Marketing, /login, /signup, /r/*, /api/* app.qrmaster.net -> /dashboard /create /analytics /settings /bulk-creation /integrations /qr/* /upgrade /onboarding, /api/* ``` ## Fixierte Entscheidungen | Frage | Entscheidung | Begründung | |---|---|---| | Hosting | Docker + Caddy auf eigenem Server | Bestand | | `/login`, `/signup` | **bleiben auf www** | Alle 82 Marketing-CTAs zeigen auf `/signup`, `/signup` hat ein hartes Canonical auf www und trägt Ad-Traffic. Umzug wäre teuer ohne Nutzen. | | `/onboarding` | zieht auf app | Reiner Logged-in-Flow, kein SEO-Wert | | Host-Wechsel | genau **einmal**, nach erfolgreichem Login/Signup | einzige Cross-Host-Stelle im ganzen Flow | | DB | keine Änderung | — | ## Teil A — Deine Aufgaben (Timo) Reihenfolge beachten: A1–A2 **vor** dem Deploy von Schritt B5, sonst zeigt die Subdomain ins Leere. ### A1. DNS CNAME `app` → auf denselben Zielhost wie `www` (bzw. A-Record auf dieselbe Server-IP). Kein Proxy-Only-Sonderfall nötig, Caddy holt das Cert selbst. ### A2. Caddyfile auf dem Server `app.qrmaster.net` in den bestehenden Site-Block aufnehmen, damit Caddy automatisch ein Let's-Encrypt-Cert zieht: ```caddyfile www.qrmaster.net, app.qrmaster.net { reverse_proxy qrmaster-web:3000 } ``` Danach `caddy reload`. Prüfen: `curl -sI https://app.qrmaster.net` muss 200 oder 301 liefern, kein TLS-Fehler. ### A3. `.env` auf dem Server ergänzen Zwei Variablen statt einer. Die Trennung ist der Kern des ganzen Umbaus: Für **Deploy 1** reicht: ```dotenv COOKIE_DOMAIN=.qrmaster.net ``` Für **Deploy 2** kommen dazu: ```dotenv NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net NEXT_PUBLIC_APP_URL=https://app.qrmaster.net ``` `NEXT_PUBLIC_APP_URL` erst zu Deploy 2 umstellen - vorher zeigt es auf www und muss dort bleiben. Fehlen die Werte, greifen die Produktions-Fallbacks in `src/lib/hosts.ts`; ein localhost-Wert kann damit nicht in gedruckte QR-Codes gelangen. `NEXTAUTH_URL` bleibt `https://www.qrmaster.net` (wird nur noch von `api/social-assets/route.ts` gelesen, kein Auth-Bezug mehr). ### A4. Google Cloud Console Bei den OAuth-Credentials als **Authorized redirect URI** zusätzlich eintragen: ``` https://app.qrmaster.net/api/auth/google ``` Die alte www-URI **nicht löschen** – sie wird während der Übergangszeit noch von Sessions genutzt, die den Flow auf www gestartet haben. ### A5. Nichts zu tun bei Stripe und TikTok - Stripe-Webhook zeigt auf `www.qrmaster.net/api/stripe/webhook` und bleibt gültig (`/api/*` wird auf beiden Hosts weiter bedient, siehe B5). - TikTok `redirect_uri` bleibt auf `qrmaster.net` – verifizierte Domain, nicht anfassen. ### A6. Deploy `npm run docker:prod` (Rebuild ist zwingend – `NEXT_PUBLIC_*` wird zur Build-Zeit ins Client-Bundle inlined, ein reiner Container-Restart genügt **nicht**). ## Teil B — Meine Aufgaben (Code), in Diff-Reihenfolge ### B1. Cookie-Domain teilen — muss zuerst live sein Ohne das ist auf `app.qrmaster.net` jeder ausgeloggt: das `userId`-Cookie ist heute host-only. - `src/lib/cookieConfig.ts:11` — `getAuthCookieOptions()`: `domain: process.env.COOKIE_DOMAIN` in Prod, in Dev `undefined` (localhost verträgt keine Punkt-Domain) - `src/lib/cookieConfig.ts:24` — `getCsrfCookieOptions()`: dito - `src/middleware.ts:34` — Attribution-Cookie: dito - `src/app/(main)/api/auth/logout/route.ts:7` — **kritisch**: löscht heute host-only. Nach der Umstellung existieren bei Bestandsnutzern beide Varianten (alt host-only + neu domain-scoped). Logout muss **beide** überschreiben, sonst bleibt ein Zombie-Cookie und der Nutzer ist nicht wirklich ausgeloggt. Gilt für `userId`, `newsletter-admin` und das Attribution-Cookie. - `src/app/(main)/api/auth/google/route.ts:53,62` — OAuth-State + Post-Auth-Redirect-Cookie Kein Forced-Logout nötig: beide Cookie-Varianten tragen denselben signierten Wert, der Server akzeptiert jede. `verifySignedUserIdEdge` prüft die Signatur, das Teilen über eigene Subdomains ist unkritisch. **Dieser Schritt kann allein auf www deployt werden, bevor die Subdomain existiert** — nach außen unsichtbar, und wenn app.* dann live geht, funktionieren Sessions sofort. ### B2. `NEXT_PUBLIC_APP_URL` entflechten Die Variable bedient heute App- **und** öffentliche URLs. Jede Fundstelle einzeln zuordnen: **Muss auf `WWW_URL` (öffentlich, teils in QR-Codes kodiert):** - `src/components/dashboard/QRCodeCard.tsx:82` — **höchstes Risiko im ganzen Umbau**: Basis für die in den QR-Code kodierte `/r/`-URL. Bleibt das auf `APP_URL`, zeigen alle neu heruntergeladenen und gedruckten Codes auf die Subdomain. - `src/app/(main)/r/[slug]/route.ts:50,61,84,89` — Landing-Basis vcard/text/coupon/feedback - `src/lib/email.ts:56,562`, `src/lib/marketingEmail.ts:30` — Mail-Links auf Marketing-Inhalte - `src/app/(main)/api/auth/signup/route.ts:20` — Verify-Mail-Link - `src/app/(main)/api/stripe/checkout/route.ts:64` — `cancel_url` → `/pricing` - `src/lib/metaConversions.ts:44`, `src/app/(main)/api/auth/signup/route.ts:150` — Event-Source-URLs **Bleibt/wird `APP_URL` (eingeloggt):** - `src/app/(main)/api/stripe/checkout/route.ts:63` — `success_url` → `/dashboard` - `src/app/(main)/api/stripe/create-checkout-session/route.ts:112,128` — `appUrl` + returnPath - `src/app/(main)/api/stripe/portal/route.ts:59` — `return_url` → `/settings` - `src/lib/email.ts:505` — hartcodiertes `https://www.qrmaster.net/dashboard` im Mail-Footer - `src/app/(main)/api/auth/google/route.ts:40,97` — `redirect_uri` (deckt A4 ab) ### B3. Post-Auth-Sprung auf app.* Die einzige Cross-Host-Stelle. `sanitizeRedirectPath` (`src/lib/auth-flow.ts:4`) erlaubt bewusst nur relative Pfade — bleibt so, ich baue den Host separat davor: - `src/app/(main)/(auth)/login/ClientPage.tsx:56` und `login/LoginClient.tsx:65` - `src/app/(main)/(auth)/signup/ClientPage.tsx:70` - `src/app/(main)/api/auth/google/route.ts:224,228` — Server-Redirect - `src/app/(main)/api/auth/verify-email/route.ts:38` — setzt Cookie und redirected - `src/lib/auth-flow.ts:46` — `getPostOnboardingDestination` Muster: relativen Zielpfad wie heute bestimmen, dann `new URL(path, APP_URL)`. Weil das Auth-Cookie nach B1 auf `.qrmaster.net` gilt, ist der Nutzer nach dem Sprung sofort eingeloggt — kein Token-Handover über die URL nötig. ### B4. Onboarding-Checkliste `src/components/dashboard/OnboardingChecklist.tsx:141` verlinkt `/onboarding` mit `redirect=/dashboard`. Beide Pfade liegen nach dem Umzug auf app.* → bleibt relativ, keine Änderung. Nur verifizieren. ### B5. Middleware: Host-Routing `src/middleware.ts` — Kern des Umbaus. Der bestehende Apex-Redirect (Zeile 49) bleibt unberührt. Neu, direkt danach: - Host `app.qrmaster.net`: - `/api/*`, `/_next/*`, statische Dateien: durchlassen (Stripe-Webhook, CSRF, alles) - `protectedPaths` (Zeile 145) + `/upgrade` + `/onboarding`: bedienen wie heute - alles andere: 301 auf `WWW_URL` + gleicher Pfad - `/r/*`: 301 auf www — QR-Redirects gehören nicht auf die App-Subdomain - Host `www.qrmaster.net`: - `protectedPaths` + `/upgrade` + `/onboarding`: 301 auf `APP_URL` + Pfad + Query (damit alte Bookmarks und der Mail-Footer-Link weiter funktionieren) - Auth-Fail-Redirect (Zeile 166): zeigt auf `/signup` — das liegt auf www, also absolut auf `WWW_URL` umstellen, `redirect`-Param bleibt relativ `/login` und `/signup` bleiben in `publicPaths` und werden nur auf www bedient. ### B6. Indexierung der Subdomain dichtmachen `app.*` darf nicht in den Index, sonst Duplicate Content. - `src/middleware.ts`: auf Host `app.*` `X-Robots-Tag: noindex, nofollow` auf alle Responses - `public/robots-app.txt` neu anlegen (`User-agent: * / Disallow: /`), Middleware rewritet `/robots.txt` auf app.* dorthin. `src/app/robots.ts` bleibt für www unverändert. - `/sitemap.xml` auf app.* → 301 auf www Gute Nachricht: `/dashboard`, `/create`, `/settings` sind in `src/app/robots.ts:7` bereits disallowed und nicht in der Sitemap → **kein Ranking-Verlust durch den Umzug.** Die Canonicals sind ohnehin hart auf www verdrahtet (`src/app/(main)/layout.tsx:13`). ### B7. Docker-Env-Kette `NEXT_PUBLIC_*` wird zur Build-Zeit inlined **und** zur Laufzeit serverseitig gelesen. Beide Stellen müssen übereinstimmen, sonst gibt es Bugs, die nur im Client oder nur im Server auftreten: - `Dockerfile:34` — `NEXT_PUBLIC_APP_URL` auf `https://app.qrmaster.net`, neu `ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"` - `docker-compose.yml:58` — `NEXT_PUBLIC_WWW_URL` und `COOKIE_DOMAIN` ins `environment` des `web`-Service durchreichen - `env.example` + `.env.example` — neue Variablen dokumentieren - `src/lib/env.ts` — optional, das Schema kennt `NEXT_PUBLIC_*` bisher gar nicht ## Deploy-Choreografie Zwei Deploys, nicht einer. Das entkoppelt das Cookie-Risiko vom Routing-Risiko: 1. **Deploy 1 (nur B1):** Cookie-Domain auf `.qrmaster.net`. Nur www ist live, nach außen unsichtbar. 24 h beobachten: Login, Logout, Checkout müssen normal laufen. 2. **A1 + A2 + A4:** DNS, Caddy, Google Console. `app.qrmaster.net` antwortet, serviert aber noch dieselbe App wie www — unkritisch, weil noch nicht verlinkt und dank B6 noch nicht indexierbar. 3. **Deploy 2 (B2–B7):** Host-Routing scharf. Ab hier springt Login auf app.*. Rollback: Deploy 2 zurücknehmen. Weil das Cookie auf `.qrmaster.net` gilt, bleiben Sessions auch nach dem Rollback auf www gültig — niemand wird ausgeloggt. DNS/Caddy können stehen bleiben. ## Testcheckliste (nach Deploy 2) Jeweils über beide Hosts: - [ ] `www.qrmaster.net/dashboard` → 301 auf `app.qrmaster.net/dashboard`, eingeloggt - [ ] `app.qrmaster.net/pricing` → 301 auf www - [ ] Signup auf www → Verify-Mail → Link führt eingeloggt auf app.* - [ ] Google-Login von www aus → landet eingeloggt auf app.*/dashboard bzw. /onboarding - [ ] Logout auf app.* → auf www **auch** ausgeloggt (prüft B1, häufigster Fehler) - [ ] Checkout: Upgrade auf app.* → Stripe → `success_url` app.*/dashboard, Abbruch → www/pricing - [ ] Stripe-Portal → zurück auf app.*/settings - [ ] Stripe-Webhook feuert weiter (Dashboard → Events, keine 4xx) - [ ] **QR-Code neu anlegen + herunterladen → kodierte URL ist `www.qrmaster.net/r/`**, nicht app.* (prüft B2, das teuerste Fehlerbild) - [ ] Bestehender `/r/` redirected + trackt weiter, vcard/coupon/feedback-Landings laden - [ ] Mutation auf app.* (QR umbenennen) → CSRF greift, kein 403 - [ ] `curl -sI https://app.qrmaster.net/dashboard | grep -i x-robots-tag` → noindex - [ ] `https://app.qrmaster.net/robots.txt` → `Disallow: /` - [ ] Search Console: `app.qrmaster.net` **nicht** als Property anlegen, keine Sitemap einreichen ## Risiken | Risiko | Wo | Absicherung | |---|---|---| | Gedruckte QR-Codes zeigen auf app.* | `QRCodeCard.tsx:82` | B2, explizit im Test | | Logout wirkt nicht (Zombie-Cookie) | `logout/route.ts` | B1 löscht beide Varianten | | Client/Server-Env divergieren | `Dockerfile` vs. `docker-compose.yml` | B7, beide Stellen setzen | | Google-OAuth bricht | Cloud Console | A4, alte URI stehen lassen | | Duplicate Content auf app.* | — | B6 vor Deploy 2 | ## Aufwand - Deine Seite: ~45 min (DNS, Caddy, .env, Google Console, Deploy) - Meine Seite: ~4–6 h Code über zwei Deploys - Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust