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>
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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;
|
|
}
|