diff --git a/Dockerfile b/Dockerfile
index 9724fa5..2c7b039 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,6 +42,11 @@ ARG NEXT_PUBLIC_UMAMI_SRC=""
ARG NEXT_PUBLIC_UMAMI_ID=""
ENV NEXT_PUBLIC_UMAMI_SRC=$NEXT_PUBLIC_UMAMI_SRC
ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID
+# Shared session cookie across www.* and app.*. Needed at build time too: process.env is
+# inlined into the Edge middleware bundle, so a runtime-only value would leave the
+# middleware and the route handlers disagreeing about the cookie scope.
+ARG COOKIE_DOMAIN=""
+ENV COOKIE_DOMAIN=$COOKIE_DOMAIN
RUN npx prisma generate
RUN npm run build
diff --git a/PLAN_APP_SUBDOMAIN_2026-08-12.md b/PLAN_APP_SUBDOMAIN_2026-08-12.md
new file mode 100644
index 0000000..7ef55f7
--- /dev/null
+++ b/PLAN_APP_SUBDOMAIN_2026-08-12.md
@@ -0,0 +1,232 @@
+# 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`.
+
+## 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:
+
+```dotenv
+NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
+NEXT_PUBLIC_APP_URL=https://app.qrmaster.net
+COOKIE_DOMAIN=.qrmaster.net
+```
+
+`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
diff --git a/docker-compose.yml b/docker-compose.yml
index 44b2ef5..be5fdd3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -46,6 +46,7 @@ services:
args:
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
+ COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
container_name: qrmaster-web
restart: unless-stopped
environment:
@@ -56,6 +57,7 @@ services:
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
+ COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
diff --git a/env.example b/env.example
index 412e041..ccaf8d3 100644
--- a/env.example
+++ b/env.example
@@ -16,6 +16,11 @@ DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=your-secret-key-here-change-in-production
+# Session cookie scope. Leave EMPTY for local development (browsers reject dotted
+# domains on localhost). In production set to `.qrmaster.net` so the session is shared
+# between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production.
+COOKIE_DOMAIN=
+
# OAuth Providers (Optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
diff --git a/src/app/(main)/api/auth/google/route.ts b/src/app/(main)/api/auth/google/route.ts
index ca5189b..bbd8d92 100644
--- a/src/app/(main)/api/auth/google/route.ts
+++ b/src/app/(main)/api/auth/google/route.ts
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
-import { getAuthCookieOptions } from '@/lib/cookieConfig';
+import {
+ appendExpiredCookies,
+ getAuthCookieOptions,
+ getCookieDomain,
+ getFlowCookieOptions,
+} from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import {
appendRedirectParam,
@@ -16,8 +21,6 @@ import {
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
-const isProduction = process.env.NODE_ENV === 'production';
-
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
@@ -50,24 +53,16 @@ export async function GET(request: NextRequest) {
googleAuthUrl.searchParams.set('state', oauthState);
const response = NextResponse.redirect(googleAuthUrl);
- response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
- httpOnly: true,
- secure: isProduction,
- sameSite: 'lax',
- path: '/',
- maxAge: 60 * 10,
- });
+ response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, getFlowCookieOptions(60 * 10));
if (redirectTarget) {
- response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
- httpOnly: true,
- secure: isProduction,
- sameSite: 'lax',
- path: '/',
- maxAge: 60 * 10,
- });
+ response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, getFlowCookieOptions(60 * 10));
} else {
- response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
+ response.cookies.delete({
+ name: POST_AUTH_REDIRECT_COOKIE_NAME,
+ path: '/',
+ domain: getCookieDomain(),
+ });
}
return response;
@@ -229,17 +224,20 @@ export async function GET(request: NextRequest) {
const response = NextResponse.redirect(redirectUrl.toString());
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
- response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
- response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
- response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
+ response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
+ response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
+ // Must stay after the last cookies.set()/delete() call - see appendExpiredCookies.
+ // The attribution cookie lives 90 days, so a pre-COOKIE_DOMAIN host-only copy can
+ // still be around and has to be expired alongside the domain-scoped one.
+ appendExpiredCookies(response.headers, [{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false }]);
return response;
} catch (error) {
console.error('Google OAuth error:', error);
const errorResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
- errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
- errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
+ errorResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
+ errorResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
return errorResponse;
}
}
diff --git a/src/app/(main)/api/auth/logout/route.ts b/src/app/(main)/api/auth/logout/route.ts
index d621764..4aabd9d 100644
--- a/src/app/(main)/api/auth/logout/route.ts
+++ b/src/app/(main)/api/auth/logout/route.ts
@@ -1,30 +1,18 @@
-import { NextResponse } from 'next/server';
-import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
-
-export async function POST() {
- const response = NextResponse.json({ success: true });
-
- response.cookies.set('userId', '', {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 0,
- });
- response.cookies.set('newsletter-admin', '', {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 0,
- });
- response.cookies.set(ATTRIBUTION_COOKIE_NAME, '', {
- httpOnly: false,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 0,
- });
-
- return response;
-}
+import { NextResponse } from 'next/server';
+import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
+import { appendExpiredCookies } from '@/lib/cookieConfig';
+
+export async function POST() {
+ const response = NextResponse.json({ success: true });
+
+ // Deliberately not using response.cookies.set() here: it is keyed by cookie name, so
+ // it can only ever emit one variant per cookie. Logout has to expire both the
+ // host-only and the domain-scoped variant (see appendExpiredCookies).
+ appendExpiredCookies(response.headers, [
+ { name: 'userId', httpOnly: true },
+ { name: 'newsletter-admin', httpOnly: true },
+ { name: ATTRIBUTION_COOKIE_NAME, httpOnly: false },
+ ]);
+
+ return response;
+}
diff --git a/src/lib/cookieConfig.ts b/src/lib/cookieConfig.ts
index e868075..e5794a8 100644
--- a/src/lib/cookieConfig.ts
+++ b/src/lib/cookieConfig.ts
@@ -1,39 +1,138 @@
-/**
- * Cookie configuration helpers
- * Automatically uses secure settings in production
- */
-
-const isProduction = process.env.NODE_ENV === 'production';
-
-/**
- * Get cookie options for authentication cookies
- */
-export function getAuthCookieOptions() {
- return {
- httpOnly: true,
- secure: isProduction, // HTTPS only in production
- sameSite: 'lax' as const,
- maxAge: 60 * 60 * 24 * 7, // 7 days
- };
-}
-
-/**
- * Get cookie options for CSRF tokens
- * Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
- */
-export function getCsrfCookieOptions() {
- return {
- httpOnly: false, // Client needs to read this token for the header
- secure: isProduction, // HTTPS only in production
- sameSite: 'lax' as const,
- maxAge: 60 * 60 * 24, // 24 hours
- path: '/', // Available on all paths
- };
-}
-
-/**
- * Check if running in production
- */
-export function isProductionEnvironment(): boolean {
- return isProduction;
-}
+/**
+ * Cookie configuration helpers
+ * Automatically uses secure settings in production
+ */
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+/**
+ * Domain the session cookies are scoped to.
+ *
+ * Set `COOKIE_DOMAIN=.qrmaster.net` in production so one session is shared between
+ * www.qrmaster.net (marketing, login) and app.qrmaster.net (the app). Without it the
+ * cookie stays host-only and a user logged in on www would be anonymous on app.
+ *
+ * Only honoured in production on purpose: browsers reject dotted domains for
+ * `localhost`, so a prod .env copied into a dev environment would silently break
+ * every login instead of just ignoring the value.
+ */
+export function getCookieDomain(): string | undefined {
+ if (!isProduction) {
+ return undefined;
+ }
+
+ const domain = process.env.COOKIE_DOMAIN?.trim();
+
+ return domain ? domain : undefined;
+}
+
+/**
+ * Get cookie options for authentication cookies
+ */
+export function getAuthCookieOptions() {
+ return {
+ httpOnly: true,
+ secure: isProduction, // HTTPS only in production
+ sameSite: 'lax' as const,
+ path: '/', // Explicit so the expiry in buildExpiredCookieHeaders() matches
+ maxAge: 60 * 60 * 24 * 7, // 7 days
+ domain: getCookieDomain(),
+ };
+}
+
+/**
+ * Get cookie options for CSRF tokens
+ * Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
+ */
+export function getCsrfCookieOptions() {
+ return {
+ httpOnly: false, // Client needs to read this token for the header
+ secure: isProduction, // HTTPS only in production
+ sameSite: 'lax' as const,
+ maxAge: 60 * 60 * 24, // 24 hours
+ path: '/', // Available on all paths
+ domain: getCookieDomain(),
+ };
+}
+
+/**
+ * Get cookie options for short-lived flow cookies (OAuth state, post-auth redirect).
+ */
+export function getFlowCookieOptions(maxAgeSeconds: number) {
+ return {
+ httpOnly: true,
+ secure: isProduction,
+ sameSite: 'lax' as const,
+ path: '/',
+ maxAge: maxAgeSeconds,
+ domain: getCookieDomain(),
+ };
+}
+
+function serializeExpiredCookie(name: string, httpOnly: boolean, domain?: string): string {
+ const parts = [
+ `${name}=`,
+ 'Path=/',
+ 'Max-Age=0',
+ 'Expires=Thu, 01 Jan 1970 00:00:00 GMT',
+ 'SameSite=Lax',
+ ];
+
+ if (domain) {
+ parts.push(`Domain=${domain}`);
+ }
+ if (httpOnly) {
+ parts.push('HttpOnly');
+ }
+ if (isProduction) {
+ parts.push('Secure');
+ }
+
+ return parts.join('; ');
+}
+
+/**
+ * Build every `Set-Cookie` value needed to actually delete a cookie.
+ *
+ * A cookie is only removed by a Set-Cookie whose name, path AND domain match what the
+ * browser stored. Since we moved the session to a shared COOKIE_DOMAIN, a returning user
+ * can hold BOTH variants at once: a host-only cookie set before the switch and a
+ * domain-scoped one set after. Expiring only one leaves the other in place and the user
+ * stays effectively logged in — so we always emit both.
+ */
+export function buildExpiredCookieHeaders(name: string, httpOnly: boolean): string[] {
+ const domain = getCookieDomain();
+ const headers = [serializeExpiredCookie(name, httpOnly)];
+
+ if (domain) {
+ headers.push(serializeExpiredCookie(name, httpOnly, domain));
+ }
+
+ return headers;
+}
+
+/**
+ * Append expiry headers for the given cookies onto a response.
+ *
+ * IMPORTANT: call this AFTER the last `response.cookies.set()` on the same response.
+ * Next's ResponseCookies is keyed by cookie name and rewrites the whole `set-cookie`
+ * header from its internal map on every `set()`, which would drop these appends and
+ * collapse our two variants back into one.
+ */
+export function appendExpiredCookies(
+ headers: Headers,
+ cookies: Array<{ name: string; httpOnly: boolean }>
+): void {
+ for (const cookie of cookies) {
+ for (const value of buildExpiredCookieHeaders(cookie.name, cookie.httpOnly)) {
+ headers.append('set-cookie', value);
+ }
+ }
+}
+
+/**
+ * Check if running in production
+ */
+export function isProductionEnvironment(): boolean {
+ return isProduction;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index d30bbce..76001eb 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -6,6 +6,7 @@ import {
serializeAttributionCookie,
} from '@/lib/revops';
import { verifySignedUserIdEdge } from '@/lib/session-edge';
+import { getCookieDomain } from '@/lib/cookieConfig';
const isProduction = process.env.NODE_ENV === 'production';
@@ -37,6 +38,7 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 90,
+ domain: getCookieDomain(),
});
return response;