Splits the two hostnames across one deployment. No files move: the Next app still serves every route on both hosts, and the middleware decides per host which paths it owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point at /signup, which carries a hard canonical to www plus ad traffic. src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES, isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder read from it so they cannot drift apart. - Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most important: QRCodeCard encodes this origin into the QR code the user downloads and prints, so it must stay on www. - Route Stripe return URLs, email links and OAuth redirects per path rather than against one origin, so /dashboard lands on app and /pricing on www. - Cross the host boundary once, after a successful login: the router cannot push across origins, so that jump needs a full load. The user arrives signed in because the session cookie is scoped to COOKIE_DOMAIN. - Keep the app host out of search indexes: X-Robots-Tag on every response plus a Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www. - Point the TikTok callback fallback at www explicitly. It used to read NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is verified with TikTok. Host splitting is inert while both origins are equal, so development is unaffected. Verified: tsc clean, production build succeeds including the Edge middleware bundle, and the path-to-host mapping is unit-checked (prefix traps like /created and /settings-guide stay on www, query strings do not break matching). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
13 KiB
Markdown
260 lines
13 KiB
Markdown
# 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')`,
|
||
`<Link href="/settings">`) 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/<slug>`-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/<slug>`**,
|
||
nicht app.* (prüft B2, das teuerste Fehlerbild)
|
||
- [ ] Bestehender `/r/<slug>` 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
|