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 <noreply@anthropic.com>
This commit is contained in:
16
Dockerfile
16
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
|
||||
|
||||
|
||||
293
PLAN_TESTUMGEBUNG_2026-08-12.md
Normal file
293
PLAN_TESTUMGEBUNG_2026-08-12.md
Normal file
@@ -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=<eigenes Secret>
|
||||
IP_SALT=<eigenes Salt>
|
||||
|
||||
# Nicht indexieren
|
||||
NEXT_PUBLIC_INDEXABLE=false
|
||||
|
||||
# Eigene DB im Test-Stack
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=<eigenes>
|
||||
POSTGRES_DB=qrmaster
|
||||
DATABASE_URL=postgresql://postgres:<eigenes>@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
|
||||
59
docker-compose.test.yml
Normal file
59
docker-compose.test.yml
Normal file
@@ -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
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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: `<userId>.<signature>`.
|
||||
*/
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user