From 113acc073fe13212214c450ea7871eb736ee1e5c Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 12 Aug 2026 22:09:56 +0200 Subject: [PATCH] Make the session cookie name configurable for a staging deployment Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real qrmaster.net subdomain. Production scopes its session cookie to .qrmaster.net, so the browser sends it to every subdomain including staging. With both environments naming the cookie `userId`, the browser holds two cookies of the same name and cookies.get() picks one arbitrarily - staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick `userId_test` instead. Production keeps the `userId` default; changing it there would invalidate every existing session. Wired getAuthCookieName() into the six places that named the cookie literally. The account deletion route now expires both the host-only and the domain-scoped variant like the logout route already does, instead of a single cookies().delete() that would leave the other one behind. NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be built pointing at the staging host - the defaults keep a plain production build byte identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env is inlined into the Edge middleware bundle. robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise returns the production robots.txt and invites crawlers to index a duplicate of www. docker-compose.test.yml is the staging overlay. Two things it must get right, both verified against `docker compose config`: - db and redis need `networks: !override`. Compose MERGES the networks mapping from the base file, and since qrmaster-network is external and shared, a plain list left them attached to it - `db` would then resolve to two containers and staging could read and write the production database. - The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations stopped in April 2026 and the schema has moved on through manual SQL since, so applying them to a fresh database would build a stale schema. Staging gets its schema from `pg_dump --schema-only` against production instead. Verified: tsc clean, production build succeeds, and the merged compose config confirms staging keeps db/redis off the shared network while production resolves unchanged. Co-Authored-By: Claude Opus 5 --- Dockerfile | 16 +- PLAN_TESTUMGEBUNG_2026-08-12.md | 293 ++++++++++++++++++ docker-compose.test.yml | 59 ++++ docker-compose.yml | 2 + env.example | 7 + src/app/(main)/api/auth/google/route.ts | 3 +- src/app/(main)/api/auth/logout/route.ts | 4 +- src/app/(main)/api/auth/verify-email/route.ts | 4 +- src/app/(main)/api/user/delete/route.ts | 11 +- src/app/robots.ts | 11 + src/lib/cookieConfig.ts | 15 + src/lib/session.ts | 8 +- src/middleware.ts | 4 +- 13 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 PLAN_TESTUMGEBUNG_2026-08-12.md create mode 100644 docker-compose.test.yml diff --git a/Dockerfile b/Dockerfile index 032ed9a..35d5771 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,10 +31,14 @@ ENV NEXTAUTH_SECRET="build-time-secret" ENV IP_SALT="build-time-salt" ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build" ENV RESEND_API_KEY="re_placeholder_for_build" -# Marketing host vs app host. NEXT_PUBLIC_WWW_URL must stay on www: it is the origin -# encoded into downloaded QR codes and used for public email links. -ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net" -ENV NEXT_PUBLIC_APP_URL="https://app.qrmaster.net" +# Marketing host vs app host. NEXT_PUBLIC_WWW_URL must stay on www in production: it is the +# origin encoded into downloaded QR codes and used for public email links. +# Declared as ARG so the staging overlay can build the same image pointing at +# testmodul.qrmaster.net - the defaults keep a plain production build unchanged. +ARG NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net" +ENV NEXT_PUBLIC_WWW_URL=$NEXT_PUBLIC_WWW_URL +ARG NEXT_PUBLIC_APP_URL="https://app.qrmaster.net" +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL # PostHog Analytics - REQUIRED at build time for client-side bundle ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ" ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" @@ -50,6 +54,10 @@ ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID # middleware and the route handlers disagreeing about the cookie scope. ARG COOKIE_DOMAIN="" ENV COOKIE_DOMAIN=$COOKIE_DOMAIN +# Distinct session cookie name for the staging deployment, so its cookie cannot collide +# with the production one the browser also sends to testmodul.qrmaster.net. +ARG AUTH_COOKIE_NAME="" +ENV AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME RUN npx prisma generate RUN npm run build diff --git a/PLAN_TESTUMGEBUNG_2026-08-12.md b/PLAN_TESTUMGEBUNG_2026-08-12.md new file mode 100644 index 0000000..83a5819 --- /dev/null +++ b/PLAN_TESTUMGEBUNG_2026-08-12.md @@ -0,0 +1,293 @@ +# Plan: Testumgebung auf testmodul.qrmaster.net + +Stand: 2026-08-12 · Ziel: eine vollständige zweite Instanz auf `testmodul.qrmaster.net`, die +einen anderen Branch (`test`) fährt, damit riskante Features - z.B. ein Bot, der bei +Kundenerfolgen automatisch tweetet - vor dem Merge nach `master` real getestet werden können. + +**Status: nur Planung. Es wurde noch kein Code geändert.** + +## Architektur + +Zweiter, vollständig eigenständiger Compose-Stack aus demselben Repo, anderer Branch, +eigene Datenbank, eigenes Redis. Caddy routet `testmodul.qrmaster.net` auf den Test-Container. + +``` +www.qrmaster.net ─┐ +app.qrmaster.net ─┴─> qrmaster-web (Branch master, Prod-DB) +testmodul.qrmaster.net ─> qrmaster-test-web (Branch test, eigene DB) +``` + +Prod und Test teilen sich **nichts** außer dem Docker-Netzwerk `qrmaster-network` (das ist +`external: true`, darüber erreicht Caddy beide Container per Namen). + +## Ausgangslage im Repo + +Im Working Tree liegen uncommittete Änderungen, die nicht aus der Subdomain-Arbeit stammen: + +- `src/lib/cookieConfig.ts` - `getAuthCookieName()` ist angelegt (liest `AUTH_COOKIE_NAME`, + Default `userId`) +- `src/lib/session.ts` - importiert `getAuthCookieName`, **benutzt es aber nicht**; Zeile 14 + hat weiterhin `export const AUTH_COOKIE_NAME = 'userId'` + +Damit ist die Funktion aktuell wirkungslos. Die Verkabelung fehlt an 6 Stellen (siehe unten). +Vor dem Weiterbauen klären, ob diese Änderungen bewusst so stehen oder committed werden sollen. + +Ein Branch `test` existiert noch nicht. Vorhanden: `analytics`, `dynamisch`, +`feature/mockup-landing-page`, `icons`, `master`. + +## Falle 1 - Cookie-Kollision (blockierend) + +Produktion setzt das Session-Cookie `userId` auf `.qrmaster.net`. Der Browser schickt es damit +**auch an `testmodul.qrmaster.net`**. Setzt Test sein eigenes `userId` als Host-Cookie, liegen +zwei gleichnamige Cookies vor, und `req.cookies.get('userId')` in +[middleware.ts:250](src/middleware.ts:250) liefert undefiniert welches davon. Folge: Login auf +Test verhält sich sporadisch wie ausgeloggt - ein Fehlerbild, das schwer zu greifen ist, weil es +vom Cookie-Zustand des jeweiligen Browsers abhängt. + +**Lösung:** Cookie-Name pro Umgebung konfigurierbar. `getAuthCookieName()` fertig verkabeln, +Test setzt `AUTH_COOKIE_NAME=userId_test`. + +Zu ändernde Stellen: + +| Datei | Was | +|---|---| +| `src/lib/session.ts:14` | `AUTH_COOKIE_NAME`-Konstante durch `getAuthCookieName()` ersetzen (Zeilen 71, 78 ziehen nach) | +| `src/middleware.ts:250` | `req.cookies.get('userId')` → `getAuthCookieName()` | +| `src/app/(main)/api/auth/google/route.ts:227` | `cookies.set('userId', …)` | +| `src/app/(main)/api/auth/verify-email/route.ts:40` | `cookies.set('userId', …)` | +| `src/app/(main)/api/auth/logout/route.ts:12` | Eintrag in `appendExpiredCookies` | +| `src/app/(main)/api/user/delete/route.ts:77` | `cookies().delete('userId')` | + +Wie `COOKIE_DOMAIN` muss `AUTH_COOKIE_NAME` **auch zur Build-Zeit** gesetzt sein - `process.env` +wird ins Edge-Middleware-Bundle inlined. Also `ARG` + `ENV` im Dockerfile und als Build-Arg im +Compose-Override, analog zu `COOKIE_DOMAIN`. + +Achtung beim Umstellen der Produktion: ändert sich dort der Cookie-Name, werden **alle +bestehenden Sessions ungültig** (alle Nutzer ausgeloggt). Deshalb Prod beim Default `userId` +lassen und nur Test abweichen - dann passiert genau nichts. + +## Falle 2 - Host-Routing würde nach Prod umleiten + +Die Middleware kennt nur zwei Hosts. Auf `testmodul.qrmaster.net` greift der Zweig +`else if (isAppPath(path))` und schickt `/dashboard` per 301 auf **app.qrmaster.net**, also in +die Produktion. + +**Lösung ohne Codeänderung:** Test zeigt beide Origins auf sich selbst. Dann ist +`isHostSplitEnabled()` false, `getAppHostname()` liefert null, und das komplette Host-Routing +schaltet sich ab. Test läuft als Ein-Host-Umgebung mit Marketing *und* App unter einer Domain - +genau wie die lokale Entwicklung. + +Das ist der Grund, warum beide Variablen in `.env.test` identisch sein müssen. Wer dort später +aus Versehen `NEXT_PUBLIC_APP_URL=https://app.qrmaster.net` einträgt, schickt seine Testklicks +in die Produktion. + +## Falle 3 - ausgehende Nebenwirkungen + +Das ist beim Twitter-Bot der eigentliche Punkt. Eine Testumgebung, die auf echte Dienste zeigt, +ist keine Testumgebung. + +| Dienst | Auf Test | +|---|---| +| Twitter/X | eigener App-Key auf einen Test-Account, **oder** ein `DRY_RUN`-Flag, das den Tweet nur loggt | +| Stripe | Test-Keys (`sk_test_…`), eigener Webhook-Endpoint auf testmodul | +| Resend / SMTP | Test-Key oder komplett deaktivieren - sonst mailt Test an echte Kunden | +| Meta Conversions / PostHog / Umami | leer lassen, sonst verschmutzt Test die Prod-Analytics | +| Cron (Retention-Mails) | auf Test abschalten | + +Empfehlung für den Bot: das `DRY_RUN`-Flag von Anfang an einbauen, nicht erst wenn es einmal +schiefging. Ein Bot, der Kundenerfolge tweetet, ist genau die Sorte Feature, die man nicht +"kurz mal live" testen will. + +## .env.test + +```dotenv +NODE_ENV=production + +# Ein-Host-Betrieb: schaltet das Host-Routing ab +NEXT_PUBLIC_WWW_URL=https://testmodul.qrmaster.net +NEXT_PUBLIC_APP_URL=https://testmodul.qrmaster.net +NEXTAUTH_URL=https://testmodul.qrmaster.net + +# Host-Cookie, nicht .qrmaster.net - sonst leckt die Test-Session nach Prod +COOKIE_DOMAIN= +AUTH_COOKIE_NAME=userId_test + +# Anderes Secret: ein Leak auf Test kann dann keine Prod-Session fälschen +NEXTAUTH_SECRET= +IP_SALT= + +# Nicht indexieren +NEXT_PUBLIC_INDEXABLE=false + +# Eigene DB im Test-Stack +POSTGRES_USER=postgres +POSTGRES_PASSWORD= +POSTGRES_DB=qrmaster +DATABASE_URL=postgresql://postgres:@db:5432/qrmaster?schema=public + +# Test-Keys / leer, siehe Falle 3 +STRIPE_SECRET_KEY=sk_test_… +RESEND_API_KEY= +``` + +`NEXTAUTH_URL` darf hier auf testmodul zeigen - der einzige echte Leser ist die +Social-Assets-Route, und die soll auf Test ohnehin nicht gegen die verifizierte TikTok-Domain +laufen. + +## Infra + +**`docker-compose.test.yml`** als Override, das nur die Abweichungen setzt: + +- `container_name`: `qrmaster-test-db`, `qrmaster-test-redis`, `qrmaster-test-web` - die Namen + sind im Basis-File fest vergeben und würden sonst kollidieren +- Host-Ports entfernen - `5435` (db) und `8080` (adminer) sind schon von Prod belegt +- `entrypoint: ["node", "server.js"]` für `web`, siehe Migrationen oben +- Build-Args für `COOKIE_DOMAIN`, `AUTH_COOKIE_NAME`, `NEXT_PUBLIC_*` + +### Netzwerk-Isolation (kritisch) + +`db` und `redis` hängen im Basis-File am Netzwerk `qrmaster-network` +([docker-compose.yml:22](docker-compose.yml:22)), und das ist `external: true`, also für beide +Stacks dasselbe. Compose vergibt jedem Service automatisch einen Netzwerk-Alias mit seinem +Servicenamen - zwei Stacks mit einem Service `db` am selben Netzwerk heißt: **`db` löst auf +zwei Container auf und Dockers DNS wählt zufällig.** Der Test-Container könnte damit auf der +Produktionsdatenbank landen, nicht deterministisch, sondern mal so und mal so. + +Deshalb bekommt der Test-Stack ein eigenes internes Netzwerk. Nur `web` hängt zusätzlich am +geteilten Netz, damit Caddy es erreicht: + +```yaml +services: + db: + container_name: qrmaster-test-db + ports: !reset [] + networks: [test-internal] + redis: + container_name: qrmaster-test-redis + networks: [test-internal] + web: + container_name: qrmaster-test-web + entrypoint: ["node", "server.js"] + networks: [test-internal, qrmaster-network] + +networks: + test-internal: + driver: bridge +``` + +`POSTGRES_DB` auf Test **gleich lassen** (`qrmaster`): der Healthcheck hat `pg_isready -d +qrmaster` hartkodiert ([Zeile 19](docker-compose.yml:19)) und würde bei abweichendem Namen den +Container als unhealthy melden. Die Trennung kommt vom Volume, nicht vom Datenbanknamen. + +Start: + +```bash +docker compose -p qrmaster-test --env-file .env.test \ + -f docker-compose.yml -f docker-compose.test.yml up -d --build +``` + +Der Projektname `-p qrmaster-test` gibt automatisch eigene Volumes - die Test-DB kann die +Prod-DB also nicht anfassen. + +**Caddy:** + +```caddyfile +testmodul.qrmaster.net { + reverse_proxy qrmaster-test-web:3000 +} +``` + +**`robots.ts`** an `NEXT_PUBLIC_INDEXABLE` koppeln. Aktuell liefert es hart `Allow` plus +www-Sitemap; auf Test soll `Disallow: /` stehen. `NEXT_PUBLIC_INDEXABLE=false` setzt heute nur +das Meta-Tag in den beiden Layouts, nicht die robots.txt. + +## Branch-Workflow + +```bash +git checkout -b test master +git push -u origin test +``` + +Deploy auf Test: auf dem Server `git checkout test && git pull`, dann der Compose-Befehl oben. +Rebuild ist immer nötig, weil `NEXT_PUBLIC_*` und `AUTH_COOKIE_NAME` zur Build-Zeit inlined +werden. + +Ablauf für ein Feature: Branch von `test` abzweigen → auf Test deployen und prüfen → nach +`master` mergen → Prod-Deploy. `test` bleibt dauerhaft bestehen und wird regelmäßig von +`master` nachgezogen, damit er nicht wegdriftet. + +## Datenbank - entschieden: leeres Schema + +Eigene PostgreSQL-Instanz im Test-Stack, befüllt mit **Struktur ohne Zeilen**. Keine +Kundendaten verlassen die Produktion. + +```bash +# auf dem Server, aus dem Prod-Stack +docker compose exec db pg_dump -U postgres --schema-only qrmaster > /tmp/schema.sql + +# in den Test-Stack einspielen +docker compose -p qrmaster-test exec -T db psql -U postgres qrmaster < /tmp/schema.sql +``` + +Danach einen Testaccount anlegen - entweder über das Signup-Formular auf testmodul oder per +`INSERT`. + +**Warum der Dump aus Prod und nicht aus Prisma:** `prisma/migrations` steht auf April 2026. +Alles seitdem (Pulse-Spalten, `BARCODE`-Enum-Wert, ...) kam per Hand-SQL. Die Produktions-DB +ist damit die einzige Stelle, die die aktuelle Struktur kennt. + +**Warum leer statt Kopie:** beim Tweet-Bot willst du wissen, welcher Testfall den Tweet +ausgelöst hat. Mit Prod-Daten könnte es auch irgendein echter Kunde von vor Monaten gewesen sein. + +### Keine Migrationen - der Entrypoint muss überschrieben werden + +[docker/entrypoint.sh:5](docker/entrypoint.sh:5) führt bei **jedem** Container-Start +`npx prisma migrate deploy` aus. Das widerspricht der Policy aus CLAUDE.md (Schema-Änderungen +nur per Hand-SQL) und würde auf einer frischen Test-DB die 6 veralteten Migrationen anwenden - +also ein Schema vom Stand April 2026 bauen, dem alle späteren SQL-Änderungen fehlen. Die App +liefe dann in "column does not exist". + +Im Test-Override deshalb: + +```yaml +web: + entrypoint: ["node", "server.js"] +``` + +Prod bleibt unverändert. Der Schema-Dump bringt die Tabelle `_prisma_migrations` ohnehin mit, +inklusive der 6 als angewandt markierten Einträge - der Zustand ist also identisch zu Prod. + +**Separat zu klären (nicht Teil dieses Plans):** ob `prisma migrate deploy` langfristig auch +aus dem Prod-Entrypoint verschwinden soll. Heute ist es dort ein No-Op, aber es ist eine +scharfe Waffe, die bei einem versehentlich hinzugefügten Migrationsfile auf die Produktion +losgeht. + +### Spätere Schema-Änderungen + +Ein SQL-Statement, das auf Test getestet wurde, wird auf Prod **erneut von Hand** ausgeführt - +es gibt keinen automatischen Weg von Test nach Prod. Die Dateien in `sql/` sind der Ort dafür. + +## Aufwand + +| | | +|---|---| +| Cookie-Name verkabeln (6 Stellen) + Dockerfile/Compose | ~1 h | +| `docker-compose.test.yml` + `robots.ts` an INDEXABLE koppeln | ~1 h | +| `.env.test`, Secrets, Test-Keys besorgen | Timo | +| Caddy-Block + erster Deploy | ~30 min | +| DB-Variante B zusätzlich | +2-3 h für das Anonymisierungs-Skript | + +Der Twitter-Bot selbst ist davon unabhängig und noch nicht geschätzt. + +## Testcheckliste nach dem ersten Test-Deploy + +- [ ] `testmodul.qrmaster.net` lädt, gültiges Zertifikat +- [ ] `testmodul.qrmaster.net/dashboard` bleibt **auf testmodul** und springt nicht nach app.qrmaster.net +- [ ] Login auf Test funktioniert, während man in Prod eingeloggt ist - beide Sessions unabhängig +- [ ] Logout auf Test loggt **nicht** aus Prod aus (und umgekehrt) +- [ ] Im Browser liegen zwei Cookies: `userId` (Domain `.qrmaster.net`) und `userId_test` (Host `testmodul.qrmaster.net`) +- [ ] `curl -sI https://testmodul.qrmaster.net` → kein `X-Robots-Tag` nötig, aber `/robots.txt` liefert `Disallow: /` +- [ ] Test-DB enthält **keine** echten Kunden: `SELECT count(*) FROM "User";` muss die Zahl der selbst angelegten Testaccounts sein +- [ ] `docker compose -p qrmaster-test exec web env | grep DATABASE_URL` zeigt auf den Test-Container, und `SELECT count(*)` dort weicht von Prod ab - beweist, dass der `db`-Alias nicht auf Prod zeigt +- [ ] Container-Logs beim Start enthalten **kein** "Applying Prisma migrations" +- [ ] Ein Testlauf des Bots postet nichts auf dem echten Account diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..09fd620 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,59 @@ +# Test/staging overlay for testmodul.qrmaster.net. +# +# Start with: +# docker compose -p qrmaster-test --env-file .env.test \ +# -f docker-compose.yml -f docker-compose.test.yml up -d --build +# +# The project name is what keeps the data apart: `-p qrmaster-test` gives this stack its own +# volumes, so its Postgres can never touch the production one. + +services: + db: + container_name: qrmaster-test-db + # Production already publishes 5435 on the host. + ports: !reset [] + # Only on the internal network. `db` and `redis` are network aliases assigned per + # compose project, so leaving them on the shared external network would make `db` + # resolve to two containers and this stack could reach the production database. + # + # `!override` is required: compose MERGES the networks mapping from the base file, so a + # plain list would leave qrmaster-network attached and reintroduce exactly that bug. + networks: !override + - test-internal + + redis: + container_name: qrmaster-test-redis + networks: !override + - test-internal + + web: + container_name: qrmaster-test-web + # No `prisma migrate deploy` here. The migrations in prisma/migrations stopped in + # April 2026 and the schema has moved on through manual SQL since, so running them + # against a fresh database would build a stale schema the app cannot work with. + # Bring the schema in with `pg_dump --schema-only` from production instead. + entrypoint: ["node", "server.js"] + build: + args: + # Host-only cookie on staging, so `:-` (empty) is the correct value here. + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + # These three use `:?` on purpose: an empty value would silently fall back to the + # production defaults baked into the Dockerfile, and the staging frontend would then + # talk to production. Better to fail the build with a readable message. + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:?set AUTH_COOKIE_NAME in .env.test, e.g. userId_test} + NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:?set NEXT_PUBLIC_WWW_URL in .env.test to https://testmodul.qrmaster.net} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env.test to https://testmodul.qrmaster.net} + # Reachable by Caddy over the shared network, everything else stays internal. + networks: + - test-internal + - qrmaster-network + + adminer: + container_name: qrmaster-test-adminer + ports: !reset [] + networks: !override + - test-internal + +networks: + test-internal: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 1a0ccb6..6580a69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} container_name: qrmaster-web restart: unless-stopped environment: @@ -59,6 +60,7 @@ services: NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} 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 1fe2f2b..7e207a5 100644 --- a/env.example +++ b/env.example @@ -21,6 +21,13 @@ NEXTAUTH_SECRET=your-secret-key-here-change-in-production # between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production. COOKIE_DOMAIN= +# Name of the session cookie. Leave empty in production and development (defaults to +# `userId`). The staging deployment on testmodul.qrmaster.net must set its own name, e.g. +# `userId_test`: production scopes its cookie to .qrmaster.net, so the browser sends it to +# every subdomain, and two cookies with the same name make the lookup ambiguous. +# Changing this in production logs out every user. +AUTH_COOKIE_NAME= + # Host split: marketing/SEO on WWW, the logged-in app on APP. Keep both pointing at the # same origin locally so nothing redirects across hosts in development. # In production: NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net diff --git a/src/app/(main)/api/auth/google/route.ts b/src/app/(main)/api/auth/google/route.ts index a783802..1c63a7f 100644 --- a/src/app/(main)/api/auth/google/route.ts +++ b/src/app/(main)/api/auth/google/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { appendExpiredCookies, + getAuthCookieName, getAuthCookieOptions, getCookieDomain, getFlowCookieOptions, @@ -224,7 +225,7 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(urlForPath(onboardingTarget)); const response = NextResponse.redirect(redirectUrl.toString()); - response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions()); + response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions()); 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. diff --git a/src/app/(main)/api/auth/logout/route.ts b/src/app/(main)/api/auth/logout/route.ts index 4aabd9d..669f72c 100644 --- a/src/app/(main)/api/auth/logout/route.ts +++ b/src/app/(main)/api/auth/logout/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops'; -import { appendExpiredCookies } from '@/lib/cookieConfig'; +import { appendExpiredCookies, getAuthCookieName } from '@/lib/cookieConfig'; export async function POST() { const response = NextResponse.json({ success: true }); @@ -9,7 +9,7 @@ export async function POST() { // 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: getAuthCookieName(), httpOnly: true }, { name: 'newsletter-admin', httpOnly: true }, { name: ATTRIBUTION_COOKIE_NAME, httpOnly: false }, ]); diff --git a/src/app/(main)/api/auth/verify-email/route.ts b/src/app/(main)/api/auth/verify-email/route.ts index 55dd994..83b9969 100644 --- a/src/app/(main)/api/auth/verify-email/route.ts +++ b/src/app/(main)/api/auth/verify-email/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { getAuthCookieOptions } from '@/lib/cookieConfig'; +import { getAuthCookieName, getAuthCookieOptions } from '@/lib/cookieConfig'; import { signUserId } from '@/lib/session'; import { sendWelcomeEmail } from '@/lib/email'; import { appUrl, wwwUrl } from '@/lib/hosts'; @@ -37,6 +37,6 @@ export async function GET(request: NextRequest) { } const response = NextResponse.redirect(appUrl('/onboarding?email_verified=1')); - response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions()); + response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions()); return response; } diff --git a/src/app/(main)/api/user/delete/route.ts b/src/app/(main)/api/user/delete/route.ts index 6ab53ff..6c621ad 100644 --- a/src/app/(main)/api/user/delete/route.ts +++ b/src/app/(main)/api/user/delete/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { appendExpiredCookies, getAuthCookieName } from '@/lib/cookieConfig'; import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { stripe } from '@/lib/stripe'; @@ -73,10 +73,13 @@ export async function DELETE(request: NextRequest) { where: { id: userId }, }); - // Clear auth cookie - cookies().delete('userId'); + // Clear auth cookie. Same reasoning as the logout route: both the host-only and the + // domain-scoped variant have to be expired, otherwise the survivor keeps a session + // pointing at a user row that no longer exists. + const response = NextResponse.json({ success: true }); + appendExpiredCookies(response.headers, [{ name: getAuthCookieName(), httpOnly: true }]); - return NextResponse.json({ success: true }); + return response; } catch (error) { console.error('Error deleting account:', error); return NextResponse.json( diff --git a/src/app/robots.ts b/src/app/robots.ts index 6aae3da..7b1f815 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -2,6 +2,17 @@ import { MetadataRoute } from 'next'; export default function robots(): MetadataRoute.Robots { const baseUrl = 'https://www.qrmaster.net'; + + // Staging (testmodul.qrmaster.net) runs the same code on a real qrmaster.net subdomain. + // Without this it would serve the production robots.txt and invite crawlers in, competing + // with www for the same content. The layouts already emit a noindex meta tag when this + // flag is off; this closes the robots.txt half. + if (process.env.NEXT_PUBLIC_INDEXABLE !== 'true') { + return { + rules: [{ userAgent: '*', disallow: '/' }], + }; + } + const privatePaths = [ '/api/', '/dashboard/', diff --git a/src/lib/cookieConfig.ts b/src/lib/cookieConfig.ts index e5794a8..7fe193a 100644 --- a/src/lib/cookieConfig.ts +++ b/src/lib/cookieConfig.ts @@ -26,6 +26,21 @@ export function getCookieDomain(): string | undefined { return domain ? domain : undefined; } +/** + * Name of the session cookie. + * + * Configurable so a staging deployment on another qrmaster.net subdomain can pick a + * distinct name. Production scopes its cookie to `.qrmaster.net`, so the browser sends it + * to testmodul.qrmaster.net as well; two cookies with the same name would make + * `cookies.get()` ambiguous and staging logins flaky. + * + * Like COOKIE_DOMAIN this must be set at build time too, because process.env is inlined + * into the Edge middleware bundle. + */ +export function getAuthCookieName(): string { + return process.env.AUTH_COOKIE_NAME?.trim() || 'userId'; +} + /** * Get cookie options for authentication cookies */ diff --git a/src/lib/session.ts b/src/lib/session.ts index a6e68af..f3961e8 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,7 +1,7 @@ import 'server-only'; import crypto from 'crypto'; import { cookies } from 'next/headers'; -import { getAuthCookieOptions } from './cookieConfig'; +import { getAuthCookieName, getAuthCookieOptions } from './cookieConfig'; /** * Signed session cookie. @@ -11,8 +11,6 @@ import { getAuthCookieOptions } from './cookieConfig'; * detect a tampered/forged cookie and reject it. Format: `.`. */ -export const AUTH_COOKIE_NAME = 'userId'; - function getSecret(): string { const secret = process.env.NEXTAUTH_SECRET; if (!secret) { @@ -68,12 +66,12 @@ export function verifySignedUserId(value: string | undefined | null): string | n * Use this in route handlers instead of reading the `userId` cookie directly. */ export function getSessionUserId(): string | null { - return verifySignedUserId(cookies().get(AUTH_COOKIE_NAME)?.value); + return verifySignedUserId(cookies().get(getAuthCookieName())?.value); } /** * Set the signed auth cookie for the given user id (server component / route handler context). */ export function setSessionCookie(userId: string): void { - cookies().set(AUTH_COOKIE_NAME, signUserId(userId), getAuthCookieOptions()); + cookies().set(getAuthCookieName(), signUserId(userId), getAuthCookieOptions()); } diff --git a/src/middleware.ts b/src/middleware.ts index 7070490..3f18237 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,7 +6,7 @@ import { serializeAttributionCookie, } from '@/lib/revops'; import { verifySignedUserIdEdge } from '@/lib/session-edge'; -import { getCookieDomain } from '@/lib/cookieConfig'; +import { getAuthCookieName, getCookieDomain } from '@/lib/cookieConfig'; import { getAppOrigin, getWwwOrigin, @@ -247,7 +247,7 @@ async function routeRequest(req: NextRequest): Promise { } // For protected routes, require a validly signed userId cookie - const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value); + const userId = await verifySignedUserIdEdge(req.cookies.get(getAuthCookieName())?.value); if (!userId) { // Not authenticated - redirect to signup, which lives on the marketing host.