Files
QR-master/PLAN_APP_SUBDOMAIN_2026-08-12.md
Timo Knuth 53ef4b3b91 Serve the app on app.qrmaster.net, marketing on www
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>
2026-08-12 19:44:08 +02:00

260 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`) |
| B2B7 | 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 B2B7.
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: A1A2 **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 (B2B7):** 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: ~46 h Code über zwei Deploys
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust