Files
plunk/apps/api/src/services/UserService.ts
T
josephsellers 59aa7845ba fix: correct cookie domain for .local TLD hostnames
getCookieDomain() returns '.localhost' for hostnames ending in '.local'
(e.g., app.plunk.local), causing the browser to reject the cookie
because the domain doesn't match the request origin.

Split the .localhost and .local cases so that .local hostnames return
the actual base domain (e.g., .plunk.local).
2026-02-22 16:01:25 +00:00

104 lines
2.7 KiB
TypeScript

import dayjs from 'dayjs';
import {API_URI, NODE_ENV} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Extract base domain from URL for cookie sharing across subdomains
* e.g., "http://api.example.com" -> ".example.com"
* e.g., "http://api.localhost" -> ".localhost"
* e.g., "http://app.plunk.local" -> ".plunk.local"
*/
function getCookieDomain(): string | undefined {
if (NODE_ENV === 'development') {
return undefined;
}
try {
const url = new URL(API_URI);
const hostname = url.hostname;
// For localhost or IP addresses, don't set a domain
if (hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return undefined;
}
// Extract base domain (last two parts for most domains, or .localhost)
const parts = hostname.split('.');
if (parts.length >= 2) {
// For *.localhost, use .localhost (reserved TLD)
if (hostname.endsWith('.localhost')) {
return '.localhost';
}
// For *.local (mDNS TLD), use the actual base domain
if (hostname.endsWith('.local')) {
return `.${parts.slice(-2).join('.')}`;
}
// For other domains, use the last two parts (e.g., .example.com)
return `.${parts.slice(-2).join('.')}`;
}
return undefined;
} catch {
return undefined;
}
}
export class UserService {
public static readonly COOKIE_NAME = 'next_token';
public static async id(id: string) {
return wrapRedis(Keys.User.id(id), async () => {
return prisma.user.findUnique({where: {id}});
});
}
public static async email(email: string) {
if (!email) {
return null;
}
return wrapRedis(Keys.User.email(email), async () => {
return prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
});
}
public static async projects(userId: string) {
const memberships = await prisma.user.findUnique({where: {id: userId}}).memberships({
include: {
project: true,
},
});
return memberships ? memberships.map(({project}) => project) : [];
}
/**
* Generates cookie options
* @param expires An optional expiry for this cookie (useful for a logout)
*/
public static cookieOptions(expires?: Date) {
// Check if using HTTPS from API_URI
const isHttps = NODE_ENV === 'development' ? false : API_URI.startsWith('https://');
return {
httpOnly: true,
expires: expires ?? dayjs().add(7, 'days').toDate(),
secure: isHttps,
sameSite: isHttps ? 'none' : 'lax',
path: '/',
domain: getCookieDomain(),
} as const;
}
}